syncova-backup/packages/reports/backup_reports.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

474 lines
16 KiB
Go

package reports
import (
"context"
"fmt"
"time"
)
// buildBackupReport erzeugt Tages-, Wochen- und Monatsbericht.
//
// Die drei unterscheiden sich nur in der Aufschluesselung, nicht in den
// Kennzahlen: Der Tagesbericht listet die Laeufe einzeln, der Wochenbericht
// zusaetzlich je Tag, der Monatsbericht je Auftrag. Sie als drei getrennte
// Berichte zu schreiben hiesse, dieselbe Erfolgsquote dreimal zu berechnen —
// und beim naechsten Fund an zwei Stellen zu vergessen.
func (generator *Generator) buildBackupReport(buildContext context.Context, report *Report,
reportType ReportType) error {
statistics, statisticsError := generator.loadRunStatistics(buildContext,
report.PeriodFrom, report.PeriodTo)
if statisticsError != nil {
return statisticsError
}
overviewSection := Section{
Title: "Überblick",
Description: "Alle Läufe, die im gewählten Zeitraum begonnen wurden.",
Metrics: []Metric{
KnownMetric("Läufe insgesamt", float64(statistics.TotalRuns), UnitCount),
KnownMetric("Erfolgreich", float64(statistics.SucceededRuns), UnitCount),
KnownMetric("Teilweise fehlgeschlagen", float64(statistics.PartialRuns), UnitCount),
KnownMetric("Gescheitert", float64(statistics.FailedRuns), UnitCount),
successRateMetric(statistics),
KnownMetric("Gelesene Datenmenge", float64(statistics.BytesProcessed), UnitBytes),
KnownMetric("Abgelegte Datenmenge", float64(statistics.BytesWritten), UnitBytes),
KnownMetric("Erfasste Objekte", float64(statistics.FilesProcessed), UnitCount),
KnownMetric("Übergangene Objekte", float64(statistics.FilesSkipped), UnitCount),
averageDurationMetric(statistics),
longestDurationMetric(statistics),
},
}
if statistics.RunningRuns > 0 {
// Laufende Vorgaenge stehen ausserhalb der Quote und werden trotzdem
// genannt: Sonst wundert sich der Leser ueber die Differenz zwischen
// „Laeufe insgesamt" und der Summe der Ergebnisse.
overviewSection.Metrics = append(overviewSection.Metrics,
KnownMetric("Noch laufend", float64(statistics.RunningRuns), UnitCount))
}
report.Sections = append(report.Sections, overviewSection)
if statistics.PartialRuns > 0 {
report.AddNote("%d Lauf/Läufe endeten als Teilfehler. Sie zählen nicht als Erfolg und "+
"werden nicht selbsttätig wiederholt — die übergangenen Objekte wären beim nächsten "+
"Versuch dieselben.", statistics.PartialRuns)
}
if statistics.FinishedRuns() == 0 {
report.AddNote("Im gewählten Zeitraum wurde kein Lauf abgeschlossen. Die Kennzahlen, die " +
"einen Lauf voraussetzen, sind deshalb nicht bestimmbar und werden nicht mit null " +
"ausgewiesen.")
}
if breakdownError := generator.appendBackupBreakdown(buildContext, report, reportType); breakdownError != nil {
return breakdownError
}
return generator.appendRunTable(buildContext, report)
}
// appendBackupBreakdown ergaenzt die Aufschluesselung je Reportart.
func (generator *Generator) appendBackupBreakdown(buildContext context.Context, report *Report,
reportType ReportType) error {
switch reportType {
case TypeWeeklyBackup:
return generator.appendDailyBreakdown(buildContext, report)
case TypeMonthlyBackup:
return generator.appendJobBreakdown(buildContext, report)
default:
return nil
}
}
// appendDailyBreakdown schluesselt die Laeufe nach Tagen auf.
//
// Sie zeigt Muster, die in der Summe untergehen — etwa einen Auftrag, der
// immer freitags scheitert.
func (generator *Generator) appendDailyBreakdown(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT date_trunc('day', created_at) AS tag,
count(*),
count(*) FILTER (WHERE status = 'succeeded'),
count(*) FILTER (WHERE status = 'partial_failure'),
count(*) FILTER (WHERE status = 'failed'),
COALESCE(sum(bytes_processed), 0)
FROM backup_job_runs
WHERE created_at >= $1 AND created_at < $2
GROUP BY tag
ORDER BY tag`
dayRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
report.PeriodFrom, report.PeriodTo)
if queryError != nil {
return fmt.Errorf("die tagesaufschluesselung konnte nicht gelesen werden: %w", queryError)
}
defer dayRows.Close()
breakdownTable := Table{
Title: "Läufe je Tag",
Columns: []string{"Tag", "Läufe", "Erfolgreich", "Teilfehler", "Gescheitert",
"Gelesene Datenmenge"},
Rows: make([][]string, 0, 32),
EmptyNotice: "Im gewählten Zeitraum gab es an keinem Tag einen Lauf.",
}
for dayRows.Next() {
var (
dayStart time.Time
totalCount, succeededCount int64
partialCount, failedCount, bytesProcessedSum int64
)
if scanError := dayRows.Scan(&dayStart, &totalCount, &succeededCount,
&partialCount, &failedCount, &bytesProcessedSum); scanError != nil {
return fmt.Errorf("ein tag konnte nicht gelesen werden: %w", scanError)
}
breakdownTable.Rows = append(breakdownTable.Rows, []string{
dayStart.UTC().Format("2006-01-02"),
fmt.Sprintf("%d", totalCount),
fmt.Sprintf("%d", succeededCount),
fmt.Sprintf("%d", partialCount),
fmt.Sprintf("%d", failedCount),
FormatBytes(float64(bytesProcessedSum)),
})
}
if rowsError := dayRows.Err(); rowsError != nil {
return rowsError
}
report.Sections = append(report.Sections, Section{
Title: "Aufschlüsselung nach Tagen",
Tables: []Table{breakdownTable},
})
return nil
}
// appendJobBreakdown schluesselt die Laeufe nach Auftraegen auf.
func (generator *Generator) appendJobBreakdown(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT j.name,
count(*),
count(*) FILTER (WHERE r.status = 'succeeded'),
count(*) FILTER (WHERE r.status = 'partial_failure'),
count(*) FILTER (WHERE r.status = 'failed'),
COALESCE(sum(r.bytes_processed), 0),
COALESCE(avg(EXTRACT(EPOCH FROM (r.completed_at - r.started_at))), 0)
FROM backup_job_runs r
JOIN backup_jobs j ON j.id = r.job_id
WHERE r.created_at >= $1 AND r.created_at < $2
GROUP BY j.name
ORDER BY count(*) FILTER (WHERE r.status = 'failed') DESC, j.name`
jobRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
report.PeriodFrom, report.PeriodTo)
if queryError != nil {
return fmt.Errorf("die auftragsaufschluesselung konnte nicht gelesen werden: %w", queryError)
}
defer jobRows.Close()
breakdownTable := Table{
Title: "Läufe je Auftrag",
Columns: []string{"Auftrag", "Läufe", "Erfolgreich", "Teilfehler", "Gescheitert",
"Datenmenge", "Mittlere Laufzeit"},
Rows: make([][]string, 0, 32),
EmptyNotice: "Im gewählten Zeitraum lief kein Auftrag.",
}
for jobRows.Next() {
var (
jobName string
totalCount, succeededCount int64
partialCount, failedCount, bytesProcessedSum int64
averageSeconds float64
)
if scanError := jobRows.Scan(&jobName, &totalCount, &succeededCount, &partialCount,
&failedCount, &bytesProcessedSum, &averageSeconds); scanError != nil {
return fmt.Errorf("ein auftrag konnte nicht gelesen werden: %w", scanError)
}
breakdownTable.Rows = append(breakdownTable.Rows, []string{
jobName,
fmt.Sprintf("%d", totalCount),
fmt.Sprintf("%d", succeededCount),
fmt.Sprintf("%d", partialCount),
fmt.Sprintf("%d", failedCount),
FormatBytes(float64(bytesProcessedSum)),
FormatDuration(averageSeconds),
})
}
if rowsError := jobRows.Err(); rowsError != nil {
return rowsError
}
report.Sections = append(report.Sections, Section{
Title: "Aufschlüsselung nach Aufträgen",
Tables: []Table{breakdownTable},
})
return nil
}
// maximumListedRuns begrenzt die Zahl einzeln aufgefuehrter Laeufe.
//
// Ein Monatsbericht ueber eine grosse Anlage haette sonst zehntausend Zeilen.
// Die Begrenzung wird im Bericht **ausgesprochen** — eine stillschweigend
// gekuerzte Liste liest sich wie eine vollstaendige.
const maximumListedRuns = 500
// appendRunTable listet die einzelnen Laeufe.
func (generator *Generator) appendRunTable(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT j.name, r.status, r.trigger, r.started_at, r.completed_at,
COALESCE(r.bytes_processed, 0), COALESCE(r.files_processed, 0),
COALESCE(r.files_skipped, 0), COALESCE(r.error_code, '')
FROM backup_job_runs r
JOIN backup_jobs j ON j.id = r.job_id
WHERE r.created_at >= $1 AND r.created_at < $2
ORDER BY r.created_at DESC
LIMIT $3`
runRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
report.PeriodFrom, report.PeriodTo, maximumListedRuns)
if queryError != nil {
return fmt.Errorf("die laeufe konnten nicht gelesen werden: %w", queryError)
}
defer runRows.Close()
runTable := Table{
Title: "Einzelne Läufe",
Columns: []string{"Auftrag", "Ergebnis", "Auslöser", "Beginn", "Dauer",
"Datenmenge", "Objekte", "Übergangen", "Fehlercode"},
Rows: make([][]string, 0, 64),
EmptyNotice: "Im gewählten Zeitraum wurde kein Lauf begonnen.",
}
for runRows.Next() {
var (
jobName, runStatus, runTrigger, errorCode string
startedAt, completedAt *time.Time
bytesProcessed, filesProcessed int64
filesSkipped int64
)
if scanError := runRows.Scan(&jobName, &runStatus, &runTrigger, &startedAt, &completedAt,
&bytesProcessed, &filesProcessed, &filesSkipped, &errorCode); scanError != nil {
return fmt.Errorf("ein lauf konnte nicht gelesen werden: %w", scanError)
}
runTable.Rows = append(runTable.Rows, []string{
jobName,
runStatusLabel(runStatus),
runTrigger,
formatOptionalTime(startedAt),
formatOptionalDuration(startedAt, completedAt),
FormatBytes(float64(bytesProcessed)),
fmt.Sprintf("%d", filesProcessed),
fmt.Sprintf("%d", filesSkipped),
errorCode,
})
}
if rowsError := runRows.Err(); rowsError != nil {
return rowsError
}
if len(runTable.Rows) == maximumListedRuns {
report.AddNote("Die Liste der einzelnen Läufe ist auf %d Einträge begrenzt. Es gibt "+
"möglicherweise weitere; die Kennzahlen im Überblick zählen alle.", maximumListedRuns)
}
report.Sections = append(report.Sections, Section{
Title: "Läufe im Einzelnen",
Tables: []Table{runTable},
})
return nil
}
// buildFailedBackupReport listet die gescheiterten Laeufe.
//
// Teilfehler stehen ausdruecklich mit darin: Sie werden nicht selbsttaetig
// wiederholt und verschwinden sonst aus dem Blick — genau der Zustand, den
// dieser Bericht sichtbar machen soll.
func (generator *Generator) buildFailedBackupReport(buildContext context.Context, report *Report) error {
statistics, statisticsError := generator.loadRunStatistics(buildContext,
report.PeriodFrom, report.PeriodTo)
if statisticsError != nil {
return statisticsError
}
report.Sections = append(report.Sections, Section{
Title: "Überblick",
Description: "Gescheiterte Läufe und Teilfehler im gewählten Zeitraum.",
Metrics: []Metric{
KnownMetric("Gescheiterte Läufe", float64(statistics.FailedRuns), UnitCount),
KnownMetric("Teilfehler", float64(statistics.PartialRuns), UnitCount),
KnownMetric("Übergangene Objekte", float64(statistics.FilesSkipped), UnitCount),
KnownMetric("Läufe insgesamt", float64(statistics.TotalRuns), UnitCount),
successRateMetric(statistics),
},
})
if classError := generator.appendFailureClasses(buildContext, report); classError != nil {
return classError
}
return generator.appendFailureTable(buildContext, report)
}
// appendFailureClasses fasst die Fehler nach Klassen zusammen.
//
// Die Klasse entscheidet ueber die Behandlung: transient und network werden
// wiederholt, auth und integrity nicht. Eine Haeufung in einer Klasse sagt mehr
// als zehn Einzelmeldungen.
func (generator *Generator) appendFailureClasses(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT COALESCE(failure_class, 'ohne Klassifizierung'), COALESCE(error_code, ''), count(*)
FROM backup_job_runs
WHERE created_at >= $1 AND created_at < $2
AND status IN ('failed', 'partial_failure')
GROUP BY 1, 2
ORDER BY count(*) DESC`
classRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
report.PeriodFrom, report.PeriodTo)
if queryError != nil {
return fmt.Errorf("die fehlerklassen konnten nicht gelesen werden: %w", queryError)
}
defer classRows.Close()
classTable := Table{
Title: "Fehler nach Klasse",
Columns: []string{"Fehlerklasse", "Fehlercode", "Anzahl"},
Rows: make([][]string, 0, 16),
EmptyNotice: "Im gewählten Zeitraum ist kein Lauf gescheitert.",
}
for classRows.Next() {
var (
failureClass, errorCode string
occurrenceCount int64
)
if scanError := classRows.Scan(&failureClass, &errorCode, &occurrenceCount); scanError != nil {
return fmt.Errorf("eine fehlerklasse konnte nicht gelesen werden: %w", scanError)
}
classTable.Rows = append(classTable.Rows, []string{
failureClass, errorCode, fmt.Sprintf("%d", occurrenceCount),
})
}
if rowsError := classRows.Err(); rowsError != nil {
return rowsError
}
report.Sections = append(report.Sections, Section{
Title: "Fehlerbild",
Description: "Die Klasse entscheidet über die Behandlung: transiente und Netzwerkfehler werden wiederholt, Anmelde- und Integritätsfehler nicht.",
Tables: []Table{classTable},
})
return nil
}
// appendFailureTable listet die gescheiterten Laeufe einzeln.
func (generator *Generator) appendFailureTable(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT j.name, r.status, r.started_at, r.completed_at,
COALESCE(r.error_code, ''), COALESCE(r.failure_class, ''),
COALESCE(r.error_message, ''), COALESCE(r.files_skipped, 0),
r.attempt_number
FROM backup_job_runs r
JOIN backup_jobs j ON j.id = r.job_id
WHERE r.created_at >= $1 AND r.created_at < $2
AND r.status IN ('failed', 'partial_failure')
ORDER BY r.created_at DESC
LIMIT $3`
failureRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
report.PeriodFrom, report.PeriodTo, maximumListedRuns)
if queryError != nil {
return fmt.Errorf("die gescheiterten laeufe konnten nicht gelesen werden: %w", queryError)
}
defer failureRows.Close()
failureTable := Table{
Title: "Gescheiterte Läufe und Teilfehler",
Columns: []string{"Auftrag", "Ergebnis", "Beginn", "Versuch", "Fehlercode",
"Klasse", "Übergangen", "Meldung"},
Rows: make([][]string, 0, 32),
EmptyNotice: "Im gewählten Zeitraum ist kein Lauf gescheitert und keiner endete als " +
"Teilfehler.",
}
for failureRows.Next() {
var (
jobName, runStatus, errorCode string
failureClass, errorMessage string
startedAt, completedAt *time.Time
filesSkipped int64
attemptNumber int32
)
if scanError := failureRows.Scan(&jobName, &runStatus, &startedAt, &completedAt,
&errorCode, &failureClass, &errorMessage, &filesSkipped, &attemptNumber); scanError != nil {
return fmt.Errorf("ein gescheiterter lauf konnte nicht gelesen werden: %w", scanError)
}
failureTable.Rows = append(failureTable.Rows, []string{
jobName,
runStatusLabel(runStatus),
formatOptionalTime(startedAt),
fmt.Sprintf("%d", attemptNumber),
errorCode,
failureClass,
fmt.Sprintf("%d", filesSkipped),
errorMessage,
})
}
if rowsError := failureRows.Err(); rowsError != nil {
return rowsError
}
report.Sections = append(report.Sections, Section{
Title: "Im Einzelnen",
Tables: []Table{failureTable},
})
return nil
}
// formatOptionalTime stellt einen Zeitpunkt dar, der fehlen kann.
func formatOptionalTime(pointInTime *time.Time) string {
if pointInTime == nil {
return "—"
}
return FormatTimestamp(*pointInTime)
}
// formatOptionalDuration stellt eine Dauer aus zwei Zeitpunkten dar.
//
// Fehlt einer von beiden, steht dort ein Gedankenstrich und keine Null: Ein
// abgebrochener Lauf hat keine Dauer von null Sekunden.
func formatOptionalDuration(startedAt, completedAt *time.Time) string {
if startedAt == nil || completedAt == nil {
return "—"
}
return FormatDuration(completedAt.Sub(*startedAt).Seconds())
}