Ctrl K
ComunidadGuías…

server/internal/handlers/webhook_uptime.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
	"time"

	"blackbox/server/internal/correlation"
	"blackbox/server/internal/hub"
	"blackbox/shared/types"
	"github.com/oklog/ulid/v2"
	"gorm.io/gorm"
)

type uptimePayload struct {
	Heartbeat struct {
		Status int    `json:"status"`
		Time   string `json:"time"`
		Msg    string `json:"msg"`
	} `json:"heartbeat"`
	Monitor struct {
		Name string `json:"name"`
	} `json:"monitor"`
}

func WebhookUptime(database *gorm.DB, h *hub.Hub, incidentCh chan<- types.Entry, shutdown <-chan struct{}) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			RecordWebhookDelivery(database, "uptime_kuma", "", "", "error", "failed to read payload")
			writeError(w, http.StatusBadRequest, "failed to read request body")
			return
		}

		var payload uptimePayload
		snippet := payloadSnippet(body)
		if err := json.Unmarshal(body, &payload); err != nil {
			RecordWebhookDelivery(database, "uptime_kuma", snippet, "", "error", "malformed JSON")
			writeError(w, http.StatusBadRequest, "invalid JSON")
			return
		}
		if payload.Monitor.Name == "" {
			RecordWebhookDelivery(database, "uptime_kuma", snippet, "", "ignored", "monitor.name is required")
			writeError(w, http.StatusBadRequest, "monitor.name is required")
			return
		}

		rawServiceName := strings.TrimSpace(payload.Monitor.Name)
		serviceName := strings.ToLower(strings.TrimSpace(rawServiceName))
		if serviceName == "" {
			RecordWebhookDelivery(database, "uptime_kuma", snippet, "", "ignored", "service name is required")
			writeError(w, http.StatusBadRequest, "service name is required")
			return
		}
		serviceCandidates := uniqueServiceNames(serviceName, rawServiceName)
		ts, timeFallback := parseWebhookTime(payload.Heartbeat.Time)

		meta := map[string]interface{}{
			"monitor": payload.Monitor.Name,
		}
		if timeFallback {
			meta["time_fallback"] = true
		}

		event := "up"
		content := fmt.Sprintf("Monitor '%s' recovered", payload.Monitor.Name)
		correlatedID := ""

		if payload.Heartbeat.Status == 0 {
			event = "down"
			content = fmt.Sprintf("Monitor '%s' is down: %s", payload.Monitor.Name, payload.Heartbeat.Msg)
			meta["status"] = "down"

			if !timeFallback {
				// Webhook triggers have no compose service context and no node,
				// so pass empty node to keep cross-node matching and pass "" to skip
				// docker compose-service filtering and consider all candidates for the project.
				candidates, err := correlation.ScoreCauses(database, serviceCandidates, "", ts, "")
				if err != nil {
					log.Printf("correlation lookup failed for %s: %v", payload.Monitor.Name, err)
				} else if len(candidates) > 0 {
					best := candidates[0]
					correlatedID = best.Entry.ID
					meta["possible_cause"] = best.Entry.Content
					meta["cause_node"] = best.Entry.NodeName
					meta["cause_event"] = best.Entry.Event
					meta["cause_entry_id"] = best.Entry.ID
					meta["cause_score"] = best.Score
				}
			} else {
				log.Printf("skipping correlation for %s due to time fallback", payload.Monitor.Name)
			}
		} else {
			meta["status"] = "up"
			meta["recovery_msg"] = payload.Heartbeat.Msg

			if !timeFallback {
				var downEntry types.Entry
				err := database.Where(
					"service IN ? AND event = ? AND source = ? AND timestamp <= ?",
					serviceCandidates, "down", "webhook", ts,
				).Order("timestamp DESC").First(&downEntry).Error
				switch {
				case errors.Is(err, gorm.ErrRecordNotFound):
				case err != nil:
					log.Printf("failed to load prior down event for %s: %v", payload.Monitor.Name, err)
				default:
					if ts.Before(downEntry.Timestamp) {
						meta["duration_seconds"] = int64(0)
					} else {
						meta["duration_seconds"] = int64(ts.Sub(downEntry.Timestamp).Seconds())
						meta["down_since"] = downEntry.Timestamp.UTC().Format(time.RFC3339)
					}
				}
			}
		}

		metaBytes, err := json.Marshal(meta)
		if err != nil {
			log.Printf("failed to marshal metadata for monitor %s: %v, meta: %+v", payload.Monitor.Name, err, meta)
			metaBytes = []byte("{}")
		}

		entry := types.Entry{
			ID:           ulid.Make().String(),
			Timestamp:    ts,
			NodeName:     "webhook",
			Source:       "webhook",
			Service:      serviceName,
			Event:        event,
			Content:      content,
			Metadata:     string(metaBytes),
			CorrelatedID: correlatedID,
		}
		if err := database.Create(&entry).Error; err != nil {
			RecordWebhookDelivery(database, "uptime_kuma", snippet, "", "error", "failed to save entry")
			writeError(w, http.StatusInternalServerError, "failed to save entry")
			return
		}
		dispatchToIncidentChannelWithShutdown(incidentCh, shutdown, entry)
		if h != nil {
			if msg := MarshalWSMessage("entry", entry); msg != nil {
				h.Broadcast(msg)
			}
		}

		RecordWebhookDelivery(database, "uptime_kuma", snippet, correlatedID, "processed", "")
		w.WriteHeader(http.StatusCreated)
	}
}

func parseWebhookTime(value string) (time.Time, bool) {
	if value == "" {
		return time.Now().UTC(), true
	}

	ts, err := time.Parse(time.RFC3339, value)
	if err != nil {
		return time.Now().UTC(), true
	}

	ts = ts.UTC()
	return ts, false
}

func uniqueServiceNames(names ...string) []string {
	seen := make(map[string]struct{}, len(names))
	unique := make([]string, 0, len(names))
	for _, name := range names {
		if name == "" {
			continue
		}
		if _, ok := seen[name]; ok {
			continue
		}
		seen[name] = struct{}{}
		unique = append(unique, name)
	}
	return unique
}