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>
515 lines
19 KiB
Go
515 lines
19 KiB
Go
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ErrChartNotFound meldet ein unbekanntes Diagramm.
|
|
var ErrChartNotFound = errors.New("das diagramm ist nicht bekannt")
|
|
|
|
// ErrChartUnavailable meldet ein Diagramm ohne Datengrundlage.
|
|
var ErrChartUnavailable = errors.New("fuer dieses diagramm gibt es keine datengrundlage")
|
|
|
|
// Store bildet Zeitreihen aus der Control Plane.
|
|
type Store struct {
|
|
// connectionPool ist der Datenbankpool der Control Plane.
|
|
connectionPool *pgxpool.Pool
|
|
}
|
|
|
|
// NewStore erzeugt die Datenzugriffsschicht.
|
|
func NewStore(connectionPool *pgxpool.Pool) *Store {
|
|
return &Store{connectionPool: connectionPool}
|
|
}
|
|
|
|
// BuildChart erzeugt ein Diagramm fuer den angegebenen Zeitraum.
|
|
func (store *Store) BuildChart(buildContext context.Context, metricName string, window Window, repositoryFilter *uuid.UUID) (*Chart, error) {
|
|
chartDefinition, isKnown := FindChartDefinition(metricName)
|
|
if !isKnown {
|
|
return nil, fmt.Errorf("%w: %s", ErrChartNotFound, metricName)
|
|
}
|
|
|
|
if !chartDefinition.Available {
|
|
// Ein Diagramm ohne Datengrundlage liefert keine leere Kurve, sondern
|
|
// einen Fehler mit Begruendung. Eine leere Kurve sieht aus wie eine
|
|
// Anlage ohne Betrieb.
|
|
return nil, fmt.Errorf("%w: %s", ErrChartUnavailable, chartDefinition.UnavailableReason)
|
|
}
|
|
|
|
chart := &Chart{
|
|
Metric: chartDefinition.Metric,
|
|
Title: chartDefinition.Title,
|
|
Description: chartDefinition.Description,
|
|
Window: window,
|
|
}
|
|
|
|
var buildError error
|
|
|
|
switch metricName {
|
|
case MetricBackupOutcome:
|
|
chart.Series, buildError = store.buildBackupOutcomeSeries(buildContext, window)
|
|
case MetricBackupDuration:
|
|
chart.Series, buildError = store.buildBackupDurationSeries(buildContext, window)
|
|
case MetricBackupThroughput:
|
|
chart.Series, buildError = store.buildBackupThroughputSeries(buildContext, window)
|
|
case MetricBackupVolume:
|
|
chart.Series, buildError = store.buildBackupVolumeSeries(buildContext, window)
|
|
case MetricStorageGrowth:
|
|
chart.Series, buildError = store.buildSampleSeries(buildContext, window, sampleStorageUsed,
|
|
"Belegt", UnitBytes, repositoryFilter)
|
|
case MetricSecurityScore:
|
|
chart.Series, buildError = store.buildSampleSeries(buildContext, window, sampleSecurityScore,
|
|
"Sicherheitsbewertung", UnitPercent, nil)
|
|
case MetricRepositoryCapacity:
|
|
chart.Series, buildError = store.buildSampleSeries(buildContext, window, sampleStorageUsedPercent,
|
|
"Auslastung", UnitPercent, repositoryFilter)
|
|
case MetricDeduplication:
|
|
chart.Series, buildError = store.buildSavingsSeries(buildContext, window, true)
|
|
case MetricCompression:
|
|
chart.Series, buildError = store.buildSavingsSeries(buildContext, window, false)
|
|
case MetricRecoveryPointAge:
|
|
chart.Series, buildError = store.buildRecoveryPointAgeSeries(buildContext, window)
|
|
case MetricRecoveryDuration:
|
|
chart.Series, buildError = store.buildRecoveryDurationSeries(buildContext, window)
|
|
case MetricVerificationOutcome:
|
|
chart.Series, buildError = store.buildVerificationOutcomeSeries(buildContext, window)
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s", ErrChartNotFound, metricName)
|
|
}
|
|
|
|
if buildError != nil {
|
|
return nil, buildError
|
|
}
|
|
|
|
chart.AddAssessmentNote()
|
|
|
|
return chart, nil
|
|
}
|
|
|
|
// bucketedRow ist eine Zeile einer nach Zeitfenstern gruppierten Abfrage.
|
|
type bucketedRow struct {
|
|
// BucketStart ist der Beginn des Zeitfensters.
|
|
BucketStart time.Time
|
|
// Value ist der aggregierte Wert.
|
|
Value float64
|
|
// SampleCount ist die Zahl der eingeflossenen Zeilen.
|
|
SampleCount int
|
|
}
|
|
|
|
// queryBuckets fuehrt eine nach Zeitfenstern gruppierte Abfrage aus.
|
|
//
|
|
// Die Gruppierung geschieht in PostgreSQL ueber date_bin und nicht in Go: Eine
|
|
// Million Laufzeilen in den Dienst zu holen, um sie dort zu zaehlen, waere ein
|
|
// Vielfaches an Arbeit fuer dasselbe Ergebnis.
|
|
func (store *Store) queryBuckets(queryContext context.Context, queryStatement string, arguments ...any) ([]bucketedRow, error) {
|
|
resultRows, queryError := store.connectionPool.Query(queryContext, queryStatement, arguments...)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die zeitreihe konnte nicht gebildet werden: %w", queryError)
|
|
}
|
|
|
|
defer resultRows.Close()
|
|
|
|
collectedRows := make([]bucketedRow, 0, 64)
|
|
|
|
for resultRows.Next() {
|
|
var collectedRow bucketedRow
|
|
|
|
if scanError := resultRows.Scan(&collectedRow.BucketStart, &collectedRow.Value,
|
|
&collectedRow.SampleCount); scanError != nil {
|
|
return nil, fmt.Errorf("ein punkt der zeitreihe konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
collectedRows = append(collectedRows, collectedRow)
|
|
}
|
|
|
|
return collectedRows, resultRows.Err()
|
|
}
|
|
|
|
// fillSeries traegt die Abfrageergebnisse in die Punkte eines Fensters ein.
|
|
//
|
|
// Zeitfenster ohne Zeile bleiben ohne Wert. Genau hier entscheidet sich, ob aus
|
|
// „nichts lief" eine Null wird.
|
|
func fillSeries(seriesName string, seriesLabel string, seriesUnit SeriesUnit, window Window, collectedRows []bucketedRow) Series {
|
|
points := BuildEmptyPoints(window)
|
|
bucketCount := len(points)
|
|
|
|
for _, collectedRow := range collectedRows {
|
|
bucketIndex, isInsideWindow := BucketIndexOf(window, collectedRow.BucketStart, bucketCount)
|
|
if !isInsideWindow {
|
|
continue
|
|
}
|
|
|
|
points[bucketIndex].Value = collectedRow.Value
|
|
points[bucketIndex].HasValue = true
|
|
points[bucketIndex].SampleCount = collectedRow.SampleCount
|
|
}
|
|
|
|
return Series{Name: seriesName, Label: seriesLabel, Unit: seriesUnit, Points: points}
|
|
}
|
|
|
|
// buildBackupOutcomeSeries bildet Erfolg und Fehlschlag der Sicherungen.
|
|
func (store *Store) buildBackupOutcomeSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
// Drei Reihen aus einer Abfrage: Sie teilen sich dieselben Zeitfenster, und
|
|
// drei getrennte Abfragen laesen dieselbe Tabelle dreimal.
|
|
const outcomeStatement = `
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
count(*) FILTER (WHERE status = 'succeeded'),
|
|
count(*) FILTER (WHERE status = 'partial_failure'),
|
|
count(*) FILTER (WHERE status = 'failed')
|
|
FROM backup_job_runs
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
AND status IN ('succeeded', 'partial_failure', 'failed')
|
|
GROUP BY bucket
|
|
ORDER BY bucket`
|
|
|
|
resultRows, queryError := store.connectionPool.Query(buildContext, outcomeStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die ergebnisse der sicherungen konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer resultRows.Close()
|
|
|
|
succeededRows := make([]bucketedRow, 0, 64)
|
|
partialRows := make([]bucketedRow, 0, 8)
|
|
failedRows := make([]bucketedRow, 0, 8)
|
|
|
|
for resultRows.Next() {
|
|
var (
|
|
bucketStart time.Time
|
|
succeededCount int
|
|
partialCount int
|
|
failedCount int
|
|
)
|
|
|
|
if scanError := resultRows.Scan(&bucketStart, &succeededCount, &partialCount,
|
|
&failedCount); scanError != nil {
|
|
return nil, fmt.Errorf("ein punkt konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
totalCount := succeededCount + partialCount + failedCount
|
|
|
|
succeededRows = append(succeededRows, bucketedRow{bucketStart, float64(succeededCount), totalCount})
|
|
partialRows = append(partialRows, bucketedRow{bucketStart, float64(partialCount), totalCount})
|
|
failedRows = append(failedRows, bucketedRow{bucketStart, float64(failedCount), totalCount})
|
|
}
|
|
|
|
if rowsError := resultRows.Err(); rowsError != nil {
|
|
return nil, rowsError
|
|
}
|
|
|
|
// Die Reihenfolge ist die Anzeigereihenfolge: Erfolg unten, Befunde darauf.
|
|
return []Series{
|
|
fillSeries("succeeded", "Erfolgreich", UnitCount, window, succeededRows),
|
|
fillSeries("partial_failure", "Teilfehler", UnitCount, window, partialRows),
|
|
fillSeries("failed", "Gescheitert", UnitCount, window, failedRows),
|
|
}, nil
|
|
}
|
|
|
|
// buildBackupDurationSeries bildet die Dauer der Sicherungslaeufe.
|
|
func (store *Store) buildBackupDurationSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
// Abgebrochene Laeufe bleiben aussen vor: Ihre Dauer ist die Zeit bis zum
|
|
// Abbruch und sagt nichts ueber die Leistung der Anlage.
|
|
const durationStatement = `
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
avg(EXTRACT(EPOCH FROM (completed_at - started_at)))::float8,
|
|
count(*)::int
|
|
FROM backup_job_runs
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
AND started_at IS NOT NULL
|
|
AND status IN ('succeeded', 'partial_failure')
|
|
GROUP BY bucket
|
|
ORDER BY bucket`
|
|
|
|
collectedRows, queryError := store.queryBuckets(buildContext, durationStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
|
|
return []Series{fillSeries("duration", "Mittlere Dauer", UnitSeconds, window, collectedRows)}, nil
|
|
}
|
|
|
|
// buildBackupThroughputSeries bildet den Durchsatz der Sicherungslaeufe.
|
|
func (store *Store) buildBackupThroughputSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
// Der Durchsatz wird aus Menge und Laufzeit **neu berechnet** und nicht aus
|
|
// throughput_bps gelesen: Die gespeicherte Spalte bleibt bei kurzen Laeufen
|
|
// leer, und ein Mittelwert ueber teils fehlende Werte waere schief.
|
|
//
|
|
// Laeufe unter einer Sekunde bleiben aussen vor — bei ihnen bestimmt die
|
|
// Messungenauigkeit das Ergebnis, nicht die Leistung.
|
|
const throughputStatement = `
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
(sum(bytes_processed) / NULLIF(sum(EXTRACT(EPOCH FROM (completed_at - started_at))), 0))::float8,
|
|
count(*)::int
|
|
FROM backup_job_runs
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
AND started_at IS NOT NULL
|
|
AND status IN ('succeeded', 'partial_failure')
|
|
AND EXTRACT(EPOCH FROM (completed_at - started_at)) >= 1
|
|
AND bytes_processed > 0
|
|
GROUP BY bucket
|
|
HAVING sum(EXTRACT(EPOCH FROM (completed_at - started_at))) > 0
|
|
ORDER BY bucket`
|
|
|
|
collectedRows, queryError := store.queryBuckets(buildContext, throughputStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
|
|
return []Series{
|
|
fillSeries("throughput", "Mittlerer Durchsatz", UnitBytesPerSecond, window, collectedRows),
|
|
}, nil
|
|
}
|
|
|
|
// buildBackupVolumeSeries bildet verarbeitete und geschriebene Datenmenge.
|
|
func (store *Store) buildBackupVolumeSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
const volumeStatement = `
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
sum(bytes_processed)::float8,
|
|
sum(bytes_written)::float8,
|
|
count(*)::int
|
|
FROM backup_job_runs
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
AND status IN ('succeeded', 'partial_failure')
|
|
GROUP BY bucket
|
|
ORDER BY bucket`
|
|
|
|
resultRows, queryError := store.connectionPool.Query(buildContext, volumeStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die datenmengen konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer resultRows.Close()
|
|
|
|
processedRows := make([]bucketedRow, 0, 64)
|
|
writtenRows := make([]bucketedRow, 0, 64)
|
|
|
|
for resultRows.Next() {
|
|
var (
|
|
bucketStart time.Time
|
|
processedBytes float64
|
|
writtenBytes float64
|
|
runCount int
|
|
)
|
|
|
|
if scanError := resultRows.Scan(&bucketStart, &processedBytes, &writtenBytes,
|
|
&runCount); scanError != nil {
|
|
return nil, fmt.Errorf("ein punkt konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
processedRows = append(processedRows, bucketedRow{bucketStart, processedBytes, runCount})
|
|
writtenRows = append(writtenRows, bucketedRow{bucketStart, writtenBytes, runCount})
|
|
}
|
|
|
|
if rowsError := resultRows.Err(); rowsError != nil {
|
|
return nil, rowsError
|
|
}
|
|
|
|
return []Series{
|
|
fillSeries("processed", "Gelesen", UnitBytes, window, processedRows),
|
|
fillSeries("written", "Abgelegt", UnitBytes, window, writtenRows),
|
|
}, nil
|
|
}
|
|
|
|
// buildSavingsSeries bildet die Ersparnis durch Deduplizierung oder Kompression.
|
|
func (store *Store) buildSavingsSeries(buildContext context.Context, window Window, isDeduplication bool) ([]Series, error) {
|
|
// Deduplizierung: wie viel der Ursprungsdaten gar nicht erst abgelegt wurde.
|
|
// Kompression: wie viel die Kompression **zusaetzlich** einspart, gemessen
|
|
// an den nach Deduplizierung verbliebenen Daten. Beide Anteile getrennt zu
|
|
// fuehren ist der einzige Weg, sie nicht doppelt zu zaehlen.
|
|
savingsExpression := `
|
|
(1 - sum(unique_bytes)::float8 / NULLIF(sum(logical_bytes), 0)) * 100`
|
|
filterExpression := "logical_bytes > 0 AND unique_bytes IS NOT NULL"
|
|
|
|
if !isDeduplication {
|
|
savingsExpression = `
|
|
(1 - sum(compressed_bytes)::float8 / NULLIF(sum(unique_bytes), 0)) * 100`
|
|
filterExpression = "unique_bytes > 0 AND compressed_bytes IS NOT NULL"
|
|
}
|
|
|
|
savingsStatement := fmt.Sprintf(`
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
%s,
|
|
count(*)::int
|
|
FROM backups
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
AND status = 'complete' AND deleted_at IS NULL
|
|
AND %s
|
|
GROUP BY bucket
|
|
ORDER BY bucket`, savingsExpression, filterExpression)
|
|
|
|
collectedRows, queryError := store.queryBuckets(buildContext, savingsStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
|
|
seriesLabel := "Ersparnis durch Deduplizierung"
|
|
seriesName := "deduplication_savings"
|
|
|
|
if !isDeduplication {
|
|
seriesLabel = "Zusaetzliche Ersparnis durch Kompression"
|
|
seriesName = "compression_savings"
|
|
}
|
|
|
|
return []Series{fillSeries(seriesName, seriesLabel, UnitPercent, window, collectedRows)}, nil
|
|
}
|
|
|
|
// buildRecoveryPointAgeSeries bildet das Alter des juengsten Wiederherstellungspunkts.
|
|
func (store *Store) buildRecoveryPointAgeSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
// Anders als die uebrigen Reihen entsteht diese nicht durch Gruppieren:
|
|
// Gefragt ist fuer **jedes** Zeitfenster der Abstand zum bis dahin juengsten
|
|
// Backup — auch dann, wenn in diesem Fenster keines lief. Genau das ist der
|
|
// Sinn der Kurve: Sie steigt, solange nichts gesichert wird.
|
|
const ageStatement = `
|
|
WITH fenster AS (
|
|
SELECT generate_series($1::timestamptz, $2::timestamptz - $3::interval, $3::interval) AS bucket
|
|
)
|
|
SELECT f.bucket,
|
|
EXTRACT(EPOCH FROM (f.bucket - (
|
|
SELECT max(b.completed_at)
|
|
FROM backups b
|
|
WHERE b.status = 'complete' AND b.deleted_at IS NULL
|
|
AND b.completed_at <= f.bucket
|
|
)))::float8 AS alter_sekunden,
|
|
1 AS anzahl
|
|
FROM fenster f
|
|
ORDER BY f.bucket`
|
|
|
|
collectedRows, queryError := store.queryBucketsAllowingNull(buildContext, ageStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
|
|
return []Series{
|
|
fillSeries("recovery_point_age", "Alter des juengsten Punktes", UnitSeconds, window, collectedRows),
|
|
}, nil
|
|
}
|
|
|
|
// queryBucketsAllowingNull liest Zeitfenster, in denen der Wert fehlen darf.
|
|
//
|
|
// Ein NULL bedeutet hier „zu diesem Zeitpunkt gab es noch kein Backup". Die
|
|
// Zeile wird uebersprungen, statt eine Null einzutragen: Vor dem ersten Backup
|
|
// hat der juengste Wiederherstellungspunkt kein Alter, nicht das Alter null.
|
|
func (store *Store) queryBucketsAllowingNull(queryContext context.Context, queryStatement string, arguments ...any) ([]bucketedRow, error) {
|
|
resultRows, queryError := store.connectionPool.Query(queryContext, queryStatement, arguments...)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die zeitreihe konnte nicht gebildet werden: %w", queryError)
|
|
}
|
|
|
|
defer resultRows.Close()
|
|
|
|
collectedRows := make([]bucketedRow, 0, 64)
|
|
|
|
for resultRows.Next() {
|
|
var (
|
|
bucketStart time.Time
|
|
nullableVal *float64
|
|
sampleCount int
|
|
)
|
|
|
|
if scanError := resultRows.Scan(&bucketStart, &nullableVal, &sampleCount); scanError != nil {
|
|
return nil, fmt.Errorf("ein punkt der zeitreihe konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
if nullableVal == nil {
|
|
continue
|
|
}
|
|
|
|
collectedRows = append(collectedRows, bucketedRow{bucketStart, *nullableVal, sampleCount})
|
|
}
|
|
|
|
return collectedRows, resultRows.Err()
|
|
}
|
|
|
|
// buildRecoveryDurationSeries bildet die Dauer der Wiederherstellungen.
|
|
func (store *Store) buildRecoveryDurationSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
const durationStatement = `
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
avg(EXTRACT(EPOCH FROM (completed_at - started_at)))::float8,
|
|
count(*)::int
|
|
FROM restore_jobs
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
AND started_at IS NOT NULL
|
|
AND status IN ('succeeded', 'partial_failure')
|
|
GROUP BY bucket
|
|
ORDER BY bucket`
|
|
|
|
collectedRows, queryError := store.queryBuckets(buildContext, durationStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
|
|
return []Series{
|
|
fillSeries("recovery_duration", "Mittlere Dauer", UnitSeconds, window, collectedRows),
|
|
}, nil
|
|
}
|
|
|
|
// buildVerificationOutcomeSeries bildet die Ergebnisse der Pruefungen.
|
|
func (store *Store) buildVerificationOutcomeSeries(buildContext context.Context, window Window) ([]Series, error) {
|
|
const outcomeStatement = `
|
|
SELECT date_bin($3::interval, completed_at, $1::timestamptz) AS bucket,
|
|
count(*) FILTER (WHERE result = 'clean'),
|
|
count(*) FILTER (WHERE result IN ('corrupted', 'incomplete')),
|
|
count(*) FILTER (WHERE status = 'failed')
|
|
FROM verification_jobs
|
|
WHERE completed_at >= $1 AND completed_at < $2
|
|
GROUP BY bucket
|
|
ORDER BY bucket`
|
|
|
|
resultRows, queryError := store.connectionPool.Query(buildContext, outcomeStatement,
|
|
window.From, window.To, window.BucketWidth.String())
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die pruefergebnisse konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer resultRows.Close()
|
|
|
|
cleanRows := make([]bucketedRow, 0, 16)
|
|
findingRows := make([]bucketedRow, 0, 8)
|
|
failedRows := make([]bucketedRow, 0, 8)
|
|
|
|
for resultRows.Next() {
|
|
var (
|
|
bucketStart time.Time
|
|
cleanCount int
|
|
findingCount int
|
|
failedCount int
|
|
)
|
|
|
|
if scanError := resultRows.Scan(&bucketStart, &cleanCount, &findingCount,
|
|
&failedCount); scanError != nil {
|
|
return nil, fmt.Errorf("ein punkt konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
totalCount := cleanCount + findingCount + failedCount
|
|
|
|
cleanRows = append(cleanRows, bucketedRow{bucketStart, float64(cleanCount), totalCount})
|
|
findingRows = append(findingRows, bucketedRow{bucketStart, float64(findingCount), totalCount})
|
|
failedRows = append(failedRows, bucketedRow{bucketStart, float64(failedCount), totalCount})
|
|
}
|
|
|
|
if rowsError := resultRows.Err(); rowsError != nil {
|
|
return nil, rowsError
|
|
}
|
|
|
|
// „Nicht durchfuehrbar" steht neben „Befund" und wird nicht mit ihm
|
|
// vermengt: Dass eine Pruefung nicht laufen konnte, sagt nichts ueber das
|
|
// Backup (Phase 10).
|
|
return []Series{
|
|
fillSeries("clean", "Ohne Beanstandung", UnitCount, window, cleanRows),
|
|
fillSeries("finding", "Mit Befund", UnitCount, window, findingRows),
|
|
fillSeries("failed", "Nicht durchfuehrbar", UnitCount, window, failedRows),
|
|
}, nil
|
|
}
|