syncova-backup/packages/metrics/series.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

324 lines
11 KiB
Go

// Package metrics bildet Zeitreihen fuer die Diagramme der Oberflaeche.
//
// Die wichtigste Regel des Pakets steht in einem Satz: **Eine Luecke ist keine
// Null.** Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz — nicht null
// Byte je Sekunde. Wer Luecken als Nullen zeichnet, erzeugt eine Kurve, die auf
// den Boden faellt, sobald nachts nichts lief; die Anlage sieht dann aus, als
// waere ihre Leistung eingebrochen, obwohl sie nur nichts zu tun hatte.
//
// Deshalb traegt jeder Punkt ein HasValue. Die Oberflaeche zeichnet dort nichts,
// statt eine Linie durch den Nullpunkt zu ziehen.
package metrics
import (
"errors"
"fmt"
"time"
)
// TimeRange ist ein vorgegebener Auswertungszeitraum.
type TimeRange string
const (
// RangeLastHour umfasst die letzte Stunde.
RangeLastHour TimeRange = "1h"
// RangeLastDay umfasst die letzten 24 Stunden.
RangeLastDay TimeRange = "24h"
// RangeLastWeek umfasst die letzten sieben Tage.
RangeLastWeek TimeRange = "7d"
// RangeLastMonth umfasst die letzten 30 Tage.
RangeLastMonth TimeRange = "30d"
// RangeLastQuarter umfasst die letzten 90 Tage.
RangeLastQuarter TimeRange = "90d"
// RangeLastYear umfasst das letzte Jahr.
RangeLastYear TimeRange = "1y"
// RangeCustom ist ein frei gewaehlter Zeitraum.
RangeCustom TimeRange = "custom"
)
// ErrInvalidRange meldet einen unbrauchbaren Zeitraum.
var ErrInvalidRange = errors.New("der auswertungszeitraum ist unbrauchbar")
// Window ist ein aufgeloester Auswertungszeitraum.
type Window struct {
// Range ist der angeforderte Zeitraum.
Range TimeRange `json:"range"`
// From ist der Beginn in UTC.
From time.Time `json:"from"`
// To ist das Ende in UTC.
To time.Time `json:"to"`
// BucketWidth ist die Breite eines Zeitfensters.
//
// Sie folgt dem Zeitraum, nicht dem Geschmack: Bei einem Jahr in
// Fuenfminutenschritten entstuenden ueber hunderttausend Punkte, die kein
// Diagramm darstellen kann und kein Browser gern laedt.
BucketWidth time.Duration `json:"bucket_width_seconds,omitempty"`
}
// BucketCount liefert die Zahl der Zeitfenster.
//
// Aufgerundet, nicht abgerundet. Der Unterschied trifft genau den Zeitraum, in
// dem er am meisten schmerzt: Ein Jahr geteilt durch sieben Tage ergibt 52,14
// Fenster. Abgerundet fielen die letzten 1 bis 7 Tage aus der Auswertung — also
// ausgerechnet die juengsten Ereignisse. Der Jahresverlauf zeigte dann alles bis
// vorletzte Woche und nichts danach.
//
// Das letzte Fenster ist dadurch kuerzer als die uebrigen. Das ist der bessere
// Tausch: Ein leicht schmaleres Fenster am Rand faellt niemandem auf, eine
// fehlende Woche schon.
func (window Window) BucketCount() int {
if window.BucketWidth <= 0 {
return 0
}
windowDuration := window.To.Sub(window.From)
fullBuckets := int(windowDuration / window.BucketWidth)
if windowDuration%window.BucketWidth != 0 {
fullBuckets++
}
return fullBuckets
}
// maximumBucketCount begrenzt die Zahl der Punkte einer Reihe.
//
// Mehr Punkte als Bildpunkte in der Breite eines Diagramms bringen keinen
// Erkenntnisgewinn, kosten aber Speicher, Bandbreite und Rechenzeit.
const maximumBucketCount = 500
// ResolveWindow bildet aus einem Zeitraum ein Auswertungsfenster.
//
// Die Bucket-Breite ist bewusst fest je Zeitraum und nicht frei waehlbar: Eine
// Kurve, deren Punktabstand der Aufrufer bestimmt, laesst sich zwischen zwei
// Ansichten nicht vergleichen.
func ResolveWindow(requestedRange TimeRange, customFrom time.Time, customTo time.Time, referenceTime time.Time) (Window, error) {
endTime := referenceTime.UTC()
switch requestedRange {
case RangeLastHour:
return Window{Range: requestedRange, From: endTime.Add(-time.Hour), To: endTime, BucketWidth: time.Minute}, nil
case RangeLastDay:
return Window{Range: requestedRange, From: endTime.Add(-24 * time.Hour), To: endTime, BucketWidth: 15 * time.Minute}, nil
case RangeLastWeek:
return Window{Range: requestedRange, From: endTime.AddDate(0, 0, -7), To: endTime, BucketWidth: time.Hour}, nil
case RangeLastMonth:
return Window{Range: requestedRange, From: endTime.AddDate(0, 0, -30), To: endTime, BucketWidth: 6 * time.Hour}, nil
case RangeLastQuarter:
return Window{Range: requestedRange, From: endTime.AddDate(0, 0, -90), To: endTime, BucketWidth: 24 * time.Hour}, nil
case RangeLastYear:
// AddDate statt einer festen Stundenzahl: Ein Jahr hat 365 oder 366
// Tage, und Zeitumstellungen verschieben die Grenzen zusaetzlich.
return Window{Range: requestedRange, From: endTime.AddDate(-1, 0, 0), To: endTime, BucketWidth: 7 * 24 * time.Hour}, nil
case RangeCustom:
return resolveCustomWindow(customFrom, customTo)
default:
return Window{}, fmt.Errorf("%w: %q ist kein bekannter zeitraum", ErrInvalidRange, requestedRange)
}
}
// resolveCustomWindow bildet ein frei gewaehltes Auswertungsfenster.
func resolveCustomWindow(customFrom time.Time, customTo time.Time) (Window, error) {
if customFrom.IsZero() || customTo.IsZero() {
return Window{}, fmt.Errorf("%w: ein eigener zeitraum braucht anfang und ende", ErrInvalidRange)
}
if !customTo.After(customFrom) {
return Window{}, fmt.Errorf("%w: das ende liegt nicht nach dem anfang", ErrInvalidRange)
}
windowDuration := customTo.Sub(customFrom)
// Die Bucket-Breite ergibt sich aus der Laenge des Zeitraums. Sie wird auf
// eine volle Minute aufgerundet, damit die Fenstergrenzen auf runden Zeiten
// liegen — sonst wandert die Beschriftung eines Diagramms bei jedem Aufruf.
bucketWidth := windowDuration / maximumBucketCount
if bucketWidth < time.Minute {
bucketWidth = time.Minute
} else {
bucketWidth = bucketWidth.Round(time.Minute)
}
return Window{
Range: RangeCustom,
From: customFrom.UTC(),
To: customTo.UTC(),
BucketWidth: bucketWidth,
}, nil
}
// DataPoint ist ein Punkt einer Zeitreihe.
type DataPoint struct {
// Timestamp ist der Beginn des Zeitfensters in UTC.
Timestamp time.Time `json:"timestamp"`
// Value ist der Wert; nur gueltig, wenn HasValue gesetzt ist.
//
// **Ohne omitempty.** Mit ihm verschwaende ein gemessener Wert von null aus
// der Antwort — null Fehlschlaege, null geschriebene Bytes — und die
// Oberflaeche laese ein undefined, obwohl HasValue true meldet. Ein Feld,
// das je nach Wert da ist oder nicht, ist die unangenehmste Sorte
// Schnittstelle: Sie funktioniert fast immer.
Value float64 `json:"value"`
// HasValue meldet, ob in diesem Zeitfenster ueberhaupt etwas gemessen wurde.
//
// Das Feld ist der Kern des Pakets. Ohne es liefert eine Zeitreihe fuer
// jedes leere Fenster eine Null, und aus „nichts lief" wird „Leistung auf
// null gefallen".
HasValue bool `json:"has_value"`
// SampleCount ist die Zahl der eingeflossenen Messungen.
//
// Sie steht am Punkt, damit sich ein Ausreisser einordnen laesst: Ein
// Mittelwert aus einer Messung ist etwas anderes als einer aus hundert.
SampleCount int `json:"sample_count"`
}
// SeriesUnit benennt die Einheit einer Zeitreihe.
type SeriesUnit string
const (
// UnitBytes sind Bytes.
UnitBytes SeriesUnit = "bytes"
// UnitBytesPerSecond sind Bytes je Sekunde.
UnitBytesPerSecond SeriesUnit = "bytes_per_second"
// UnitSeconds sind Sekunden.
UnitSeconds SeriesUnit = "seconds"
// UnitPercent sind Prozent.
UnitPercent SeriesUnit = "percent"
// UnitCount ist eine Anzahl.
UnitCount SeriesUnit = "count"
// UnitRatio ist ein Verhaeltnis.
UnitRatio SeriesUnit = "ratio"
)
// Series ist eine benannte Zeitreihe.
type Series struct {
// Name ist der maschinenlesbare Bezeichner.
Name string `json:"name"`
// Label ist die Beschriftung.
Label string `json:"label"`
// Unit ist die Einheit der Werte.
Unit SeriesUnit `json:"unit"`
// Points sind die Punkte in zeitlicher Reihenfolge.
Points []DataPoint `json:"points"`
}
// PointsWithValue zaehlt die Punkte mit einer Messung.
func (series *Series) PointsWithValue() int {
pointCount := 0
for _, dataPoint := range series.Points {
if dataPoint.HasValue {
pointCount++
}
}
return pointCount
}
// Chart ist ein vollstaendiges Diagramm mit einer oder mehreren Reihen.
type Chart struct {
// Metric ist der Bezeichner des Diagramms.
Metric string `json:"metric"`
// Title ist die Ueberschrift.
Title string `json:"title"`
// Description erklaert, was das Diagramm zeigt.
Description string `json:"description"`
// Window ist der ausgewertete Zeitraum.
Window Window `json:"window"`
// Series sind die enthaltenen Reihen.
Series []Series `json:"series"`
// Note ist ein Hinweis zur Aussagekraft.
//
// Sie erscheint, wenn die Zahlen zwar stimmen, aber wenig hergeben — etwa
// bei einem einzigen Datenpunkt. Ein Diagramm mit zwei Punkten sieht aus wie
// ein Trend und ist keiner.
Note string `json:"note,omitempty"`
}
// HasAnyData meldet, ob ueberhaupt etwas gemessen wurde.
func (chart *Chart) HasAnyData() bool {
for seriesIndex := range chart.Series {
if chart.Series[seriesIndex].PointsWithValue() > 0 {
return true
}
}
return false
}
// minimumPointsForTrend ist die Zahl der Punkte, ab der eine Kurve etwas aussagt.
const minimumPointsForTrend = 3
// AddAssessmentNote versieht ein Diagramm mit einem Hinweis zur Aussagekraft.
//
// Der Hinweis ist kein Fehler und keine Entschuldigung, sondern eine
// Einordnung — dieselbe Ueberlegung wie bei den ungemessenen Groessen der
// Recovery Assurance: Was wenig hergibt, soll nicht so aussehen, als gaebe es
// viel her.
func (chart *Chart) AddAssessmentNote() {
if !chart.HasAnyData() {
chart.Note = "In diesem Zeitraum wurde nichts gemessen. Die leere Flaeche bedeutet " +
"'keine Daten', nicht 'Wert null'."
return
}
maximumPoints := 0
for seriesIndex := range chart.Series {
if pointCount := chart.Series[seriesIndex].PointsWithValue(); pointCount > maximumPoints {
maximumPoints = pointCount
}
}
if maximumPoints < minimumPointsForTrend {
chart.Note = fmt.Sprintf("Die Reihe enthaelt nur %d Messungen. Das ist zu wenig fuer eine "+
"Aussage ueber eine Entwicklung.", maximumPoints)
}
}
// BuildEmptyPoints legt die Punkte eines Fensters ohne Werte an.
//
// Jedes Zeitfenster erscheint, auch das leere. Wuerden leere Fenster fehlen,
// entstuende eine Kurve mit unregelmaessigem Zeitabstand — und eine
// zweistuendige Luecke saehe aus wie ein Sprung.
func BuildEmptyPoints(window Window) []DataPoint {
bucketCount := window.BucketCount()
if bucketCount <= 0 {
return []DataPoint{}
}
if bucketCount > maximumBucketCount {
bucketCount = maximumBucketCount
}
points := make([]DataPoint, 0, bucketCount)
for bucketIndex := 0; bucketIndex < bucketCount; bucketIndex++ {
points = append(points, DataPoint{
Timestamp: window.From.Add(time.Duration(bucketIndex) * window.BucketWidth),
})
}
return points
}
// BucketIndexOf liefert das Zeitfenster eines Zeitpunkts.
//
// Der zweite Rueckgabewert meldet, ob der Zeitpunkt ueberhaupt im Fenster liegt.
// Ohne diese Pruefung landete ein Ereignis knapp ausserhalb des Zeitraums im
// ersten oder letzten Fenster und verfaelschte es.
func BucketIndexOf(window Window, pointInTime time.Time, bucketCount int) (int, bool) {
if pointInTime.Before(window.From) || !pointInTime.Before(window.To) {
return 0, false
}
bucketIndex := int(pointInTime.Sub(window.From) / window.BucketWidth)
if bucketIndex < 0 || bucketIndex >= bucketCount {
return 0, false
}
return bucketIndex, true
}