Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
4.5 KiB
Go
145 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|