package api import ( "encoding/json" "net/http" "os" "strconv" "strings" "sync" "time" ) type CronMetric struct { Timestamp string `json:"timestamp"` Source string `json:"source"` Hostname string `json:"hostname,omitempty"` Status string `json:"status,omitempty"` MemoryTotalMB float64 `json:"memory_total_mb,omitempty"` MemoryFreeMB float64 `json:"memory_free_mb,omitempty"` MemoryAvailMB float64 `json:"memory_available_mb,omitempty"` MemoryUsedMB float64 `json:"memory_used_mb,omitempty"` MemoryPercent float64 `json:"memory_percent,omitempty"` CpuLoad1m float64 `json:"cpu_load_1m,omitempty"` CpuLoad5m float64 `json:"cpu_load_5m,omitempty"` CpuLoad15m float64 `json:"cpu_load_15m,omitempty"` DiskTotalGB float64 `json:"disk_total_gb,omitempty"` DiskFreeGB float64 `json:"disk_free_gb,omitempty"` DiskUsedGB float64 `json:"disk_used_gb,omitempty"` UptimeSec float64 `json:"uptime_sec,omitempty"` Notes string `json:"notes,omitempty"` } var ( cronMetricMu sync.RWMutex cronMetricHistory []CronMetric cronMetricMaxKeep = 240 ) func (s *Server) handleCronMetrics(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: cronMetricMu.RLock() history := append([]CronMetric(nil), cronMetricHistory...) cronMetricMu.RUnlock() latest := CronMetric{} if len(history) > 0 { latest = history[len(history)-1] } writeAnyJSON(w, http.StatusOK, map[string]any{ "count": len(history), "latest": latest, "history": history, }) case http.MethodPost: if !allowCronIngest(r) { writeJSONError(w, http.StatusUnauthorized, "invalid cron token") return } var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json payload") return } sample := normalizeCronMetric(payload) cronMetricMu.Lock() cronMetricHistory = append(cronMetricHistory, sample) if len(cronMetricHistory) > cronMetricMaxKeep { cronMetricHistory = append([]CronMetric(nil), cronMetricHistory[len(cronMetricHistory)-cronMetricMaxKeep:]...) } count := len(cronMetricHistory) cronMetricMu.Unlock() writeAnyJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count, "latest": sample}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func allowCronIngest(r *http.Request) bool { expected := strings.TrimSpace(os.Getenv("CRON_TOKEN")) if expected == "" { return true } return strings.TrimSpace(r.Header.Get("X-Cron-Token")) == expected } func normalizeCronMetric(payload map[string]any) CronMetric { now := time.Now().UTC().Format(time.RFC3339) metric := CronMetric{ Timestamp: now, Source: textValue(payload, "source", "function", "name", "host", "hostname", "instance"), Hostname: textValue(payload, "hostname", "host", "node", "instance"), Status: textValue(payload, "status"), Notes: textValue(payload, "notes", "note", "message"), } if metric.Source == "" { metric.Source = "cron" } metric.MemoryTotalMB = numberValue(payload, "memory_total_mb", "total_memory_mb", "mem_total_mb", "mem_total") metric.MemoryFreeMB = numberValue(payload, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb") metric.MemoryAvailMB = numberValue(payload, "memory_available_mb", "available_memory_mb", "mem_available_mb", "avail_memory_mb") metric.MemoryUsedMB = numberValue(payload, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb") metric.MemoryPercent = numberValue(payload, "memory_percent", "mem_percent", "memory_usage_percent") if metric.MemoryUsedMB == 0 && metric.MemoryTotalMB > 0 && metric.MemoryFreeMB > 0 { metric.MemoryUsedMB = metric.MemoryTotalMB - metric.MemoryFreeMB } if metric.MemoryAvailMB == 0 && metric.MemoryFreeMB > 0 { metric.MemoryAvailMB = metric.MemoryFreeMB } if metric.MemoryPercent == 0 && metric.MemoryTotalMB > 0 && metric.MemoryUsedMB > 0 { metric.MemoryPercent = (metric.MemoryUsedMB / metric.MemoryTotalMB) * 100 } metric.CpuLoad1m = numberValue(payload, "cpu_load_1m", "loadavg_1m", "load_1m", "load1") metric.CpuLoad5m = numberValue(payload, "cpu_load_5m", "loadavg_5m", "load_5m", "load5") metric.CpuLoad15m = numberValue(payload, "cpu_load_15m", "loadavg_15m", "load_15m", "load15") metric.DiskTotalGB = numberValue(payload, "disk_total_gb", "total_disk_gb", "disk_total_mb", "disk_total") metric.DiskFreeGB = numberValue(payload, "disk_free_gb", "free_disk_gb", "disk_free_mb", "disk_free") metric.DiskUsedGB = numberValue(payload, "disk_used_gb", "used_disk_gb", "disk_used_mb", "disk_used") if metric.DiskUsedGB == 0 && metric.DiskTotalGB > 0 && metric.DiskFreeGB > 0 { metric.DiskUsedGB = metric.DiskTotalGB - metric.DiskFreeGB } if uptime := numberValue(payload, "uptime_sec", "uptime", "uptime_seconds"); uptime > 0 { metric.UptimeSec = uptime } if ts := textValue(payload, "timestamp", "ts", "collected_at"); ts != "" { metric.Timestamp = ts } return metric } func textValue(payload map[string]any, keys ...string) string { for _, key := range keys { if value, ok := payload[key]; ok { text := strings.TrimSpace(toString(value)) if text != "" { return text } } } return "" } func numberValue(payload map[string]any, keys ...string) float64 { for _, key := range keys { if value, ok := payload[key]; ok { if number, ok := toFloat64(value); ok { return number } } } return 0 } func toString(value any) string { switch v := value.(type) { case string: return v case []byte: return string(v) case float64: return strconv.FormatFloat(v, 'f', -1, 64) case float32: return strconv.FormatFloat(float64(v), 'f', -1, 32) case int: return strconv.FormatInt(int64(v), 10) case int64: return strconv.FormatInt(v, 10) case json.Number: return v.String() default: return "" } } func toFloat64(value any) (float64, bool) { switch v := value.(type) { case float64: return v, true case float32: return float64(v), true case int: return float64(v), true case int64: return float64(v), true case json.Number: f, err := v.Float64() return f, err == nil case string: if strings.TrimSpace(v) == "" { return 0, false } n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err == nil { return n, true } return 0, false default: return 0, false } }