package httpapi import ( "errors" "net/http" "time" "log/slog" "github.com/google/uuid" "github.com/syncova/syncova/packages/metrics" "github.com/syncova/syncova/packages/platform/logging" ) // metricsHandler bedient die Kennzahlen und Diagramme (Phase 13). type metricsHandler struct { // metricsStore bildet die Zeitreihen. metricsStore *metrics.Store // logger protokolliert technische Fehler. logger *slog.Logger } // handleListCharts bedient GET /metrics. // // Der Katalog nennt **alle** zwölf Diagramme aus dem Plan, auch das ohne // Datengrundlage. Ein weggelassenes sähe aus wie ein vergessenes; die // Oberfläche kann so anzeigen, was es gibt und was noch fehlt. func (handler *metricsHandler) handleListCharts(responseWriter http.ResponseWriter, request *http.Request) { chartCatalog := metrics.Catalog() availableCount := 0 for _, chartDefinition := range chartCatalog { if chartDefinition.Available { availableCount++ } } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "charts": chartCatalog, "available_count": availableCount, "ranges": []string{"1h", "24h", "7d", "30d", "90d", "1y", "custom"}, }) } // handleGetChart bedient GET /metrics/{metric}. func (handler *metricsHandler) handleGetChart(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) metricName := request.PathValue("metric") requestedRange := metrics.TimeRange(request.URL.Query().Get("range")) if requestedRange == "" { // Sieben Tage als Vorgabe: kurz genug, um einen Ausfall von gestern zu // zeigen, lang genug, um nicht bei jeder Nacht ohne Lauf leer zu sein. requestedRange = metrics.RangeLastWeek } customFrom, customTo, parseError := parseCustomRange(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } resolvedWindow, windowError := metrics.ResolveWindow(requestedRange, customFrom, customTo, time.Now()) if windowError != nil { WriteError(responseWriter, request, requestLogger, NewValidationError(windowError.Error())) return } var repositoryFilter *uuid.UUID if repositoryText := request.URL.Query().Get("repository_id"); repositoryText != "" { repositoryIdentifier, uuidError := uuid.Parse(repositoryText) if uuidError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Repositorykennung ist keine gültige UUID.")) return } repositoryFilter = &repositoryIdentifier } builtChart, buildError := handler.metricsStore.BuildChart(request.Context(), metricName, resolvedWindow, repositoryFilter) if buildError != nil { WriteError(responseWriter, request, requestLogger, translateMetricsError(buildError)) return } WriteSuccess(responseWriter, request, http.StatusOK, builtChart) } // parseCustomRange liest Anfang und Ende eines eigenen Zeitraums. func parseCustomRange(request *http.Request) (time.Time, time.Time, *APIError) { var customFrom, customTo time.Time if fromText := request.URL.Query().Get("from"); fromText != "" { parsedFrom, parseError := time.Parse(time.RFC3339, fromText) if parseError != nil { return customFrom, customTo, NewBadRequestError( "Der Anfangszeitpunkt ist keine gültige Angabe nach RFC 3339.") } customFrom = parsedFrom } if toText := request.URL.Query().Get("to"); toText != "" { parsedTo, parseError := time.Parse(time.RFC3339, toText) if parseError != nil { return customFrom, customTo, NewBadRequestError( "Der Endzeitpunkt ist keine gültige Angabe nach RFC 3339.") } customTo = parsedTo } return customFrom, customTo, nil } // translateMetricsError bildet Fehler der Fachschicht auf API-Fehler ab. func translateMetricsError(occurredError error) *APIError { switch { case errors.Is(occurredError, metrics.ErrChartNotFound): return NewNotFoundError("Dieses Diagramm gibt es nicht. GET /metrics nennt alle verfügbaren.") case errors.Is(occurredError, metrics.ErrChartUnavailable): // 501 und nicht 404: Das Diagramm ist vorgesehen, es fehlt nur die // Datengrundlage. Der Unterschied entscheidet, was der Aufrufer tut — // warten oder den Namen korrigieren. unavailableError := NewValidationError(occurredError.Error()) unavailableError.Code = "NOT_IMPLEMENTED" unavailableError.StatusCode = http.StatusNotImplemented return unavailableError case errors.Is(occurredError, metrics.ErrInvalidRange): return NewValidationError(occurredError.Error()) default: return NewInternalError(occurredError) } }