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>
324 lines
11 KiB
Go
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
|
|
}
|