package api import ( "encoding/json" "net/http" "os" "strconv" "strings" "sync" "time" ) // Metric — один снимок состояния, принятый через POST /metrics. type Metric 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"` } const maxHistory = 240 // Server хранит историю снимков в памяти. type Server struct { mu sync.RWMutex history []Metric } func NewServer() *Server { return &Server{} } // HandleMetrics — единственный публичный эндпоинт. // GET /metrics → отдаёт историю. // POST /metrics → сохраняет снимок. func (s *Server) HandleMetrics(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Metrics-Token") switch r.Method { case http.MethodOptions: w.WriteHeader(http.StatusNoContent) case http.MethodGet: s.mu.RLock() history := append([]Metric(nil), s.history...) s.mu.RUnlock() latest := Metric{} if len(history) > 0 { latest = history[len(history)-1] } writeJSON(w, http.StatusOK, map[string]any{ "count": len(history), "latest": latest, "history": history, }) case http.MethodPost: if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid token"}) return } var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) return } m := normalize(payload) s.mu.Lock() s.history = append(s.history, m) if len(s.history) > maxHistory { s.history = append([]Metric(nil), s.history[len(s.history)-maxHistory:]...) } count := len(s.history) s.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // authorized проверяет METRICS_TOKEN если он задан. func (s *Server) authorized(r *http.Request) bool { expected := strings.TrimSpace(os.Getenv("METRICS_TOKEN")) if expected == "" { return true } return strings.TrimSpace(r.Header.Get("X-Metrics-Token")) == expected } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } // normalize приводит произвольный payload к структуре Metric. func normalize(p map[string]any) Metric { now := time.Now().UTC().Format(time.RFC3339) m := Metric{ Timestamp: now, Source: textVal(p, "source", "function", "name", "host", "hostname", "instance"), Hostname: textVal(p, "hostname", "host", "node", "instance"), Status: textVal(p, "status"), Notes: textVal(p, "notes", "note", "message"), } if m.Source == "" { m.Source = "function" } m.MemoryTotalMB = numVal(p, "memory_total_mb", "total_memory_mb", "mem_total_mb") m.MemoryFreeMB = numVal(p, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb") m.MemoryAvailMB = numVal(p, "memory_available_mb", "available_memory_mb", "mem_available_mb") m.MemoryUsedMB = numVal(p, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb") m.MemoryPercent = numVal(p, "memory_percent", "mem_percent", "memory_usage_percent") if m.MemoryUsedMB == 0 && m.MemoryTotalMB > 0 && m.MemoryFreeMB > 0 { m.MemoryUsedMB = m.MemoryTotalMB - m.MemoryFreeMB } if m.MemoryAvailMB == 0 && m.MemoryFreeMB > 0 { m.MemoryAvailMB = m.MemoryFreeMB } if m.MemoryPercent == 0 && m.MemoryTotalMB > 0 && m.MemoryUsedMB > 0 { m.MemoryPercent = (m.MemoryUsedMB / m.MemoryTotalMB) * 100 } m.CpuLoad1m = numVal(p, "cpu_load_1m", "loadavg_1m", "load_1m", "load1") m.CpuLoad5m = numVal(p, "cpu_load_5m", "loadavg_5m", "load_5m", "load5") m.CpuLoad15m = numVal(p, "cpu_load_15m", "loadavg_15m", "load_15m", "load15") m.DiskTotalGB = numVal(p, "disk_total_gb", "total_disk_gb") m.DiskFreeGB = numVal(p, "disk_free_gb", "free_disk_gb") m.DiskUsedGB = numVal(p, "disk_used_gb", "used_disk_gb") if m.DiskUsedGB == 0 && m.DiskTotalGB > 0 && m.DiskFreeGB > 0 { m.DiskUsedGB = m.DiskTotalGB - m.DiskFreeGB } m.UptimeSec = numVal(p, "uptime_sec", "uptime", "uptime_seconds") if ts := textVal(p, "timestamp", "ts", "collected_at"); ts != "" { m.Timestamp = ts } return m } func textVal(p map[string]any, keys ...string) string { for _, k := range keys { if v, ok := p[k]; ok { if s := strings.TrimSpace(anyToString(v)); s != "" { return s } } } return "" } func numVal(p map[string]any, keys ...string) float64 { for _, k := range keys { if v, ok := p[k]; ok { if f, ok := anyToFloat64(v); ok { return f } } } return 0 } func anyToString(v any) string { switch t := v.(type) { case string: return t case float64: return strconv.FormatFloat(t, 'f', -1, 64) case json.Number: return t.String() default: return "" } } func anyToFloat64(v any) (float64, bool) { switch t := v.(type) { case float64: return t, true case float32: return float64(t), true case int: return float64(t), true case int64: return float64(t), true case json.Number: f, err := t.Float64() return f, err == nil case string: f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) return f, err == nil default: return 0, false } }