package reports import ( "context" "fmt" "time" ) // buildRepositoryReport beschreibt Belegung und Zustand der Repositories. // // Ein Zustandsbericht, kein Verlauf: Die Belegung wird nicht historisiert, ein // Bericht „vom letzten Dienstag" waere nicht zu erfuellen. Fuer den Verlauf gibt // es die Diagramme aus Phase 13. func (generator *Generator) buildRepositoryReport(buildContext context.Context, report *Report) error { const selectStatement = ` SELECT r.name, r.repository_type, r.status, r.hardened, r.capacity_bytes, r.used_bytes, r.enforcement_level, r.last_checked_at, (SELECT count(*) FROM backups b WHERE b.repository_id = r.id AND b.status = 'complete' AND b.deleted_at IS NULL) FROM repositories r ORDER BY r.name` repositoryRows, queryError := generator.connectionPool.Query(buildContext, selectStatement) if queryError != nil { return fmt.Errorf("die repositories konnten nicht gelesen werden: %w", queryError) } defer repositoryRows.Close() repositoryTable := Table{ Title: "Repositories", Columns: []string{"Name", "Art", "Zustand", "Belegt", "Kapazität", "Auslastung", "Löschschutz", "Backups", "Zuletzt geprüft"}, Rows: make([][]string, 0, 16), EmptyNotice: "Es ist kein Repository eingerichtet.", } var ( totalRepositories int unavailableCount int hardenedCount int withoutCapacityCount int totalUsedBytes int64 totalCapacityBytes int64 ) for repositoryRows.Next() { var ( repositoryName, repositoryType, repositoryStatus string isHardened bool capacityBytes, usedBytes *int64 enforcementLevel *string lastCheckedAt *time.Time backupCount int64 ) if scanError := repositoryRows.Scan(&repositoryName, &repositoryType, &repositoryStatus, &isHardened, &capacityBytes, &usedBytes, &enforcementLevel, &lastCheckedAt, &backupCount); scanError != nil { return fmt.Errorf("ein repository konnte nicht gelesen werden: %w", scanError) } totalRepositories++ if repositoryStatus == "unavailable" { unavailableCount++ } if isHardened { hardenedCount++ } if usedBytes != nil { totalUsedBytes += *usedBytes } if capacityBytes != nil && *capacityBytes > 0 { totalCapacityBytes += *capacityBytes } else { withoutCapacityCount++ } repositoryTable.Rows = append(repositoryTable.Rows, []string{ repositoryName, repositoryType, repositoryStatusLabel(repositoryStatus), formatOptionalBytes(usedBytes), formatOptionalBytes(capacityBytes), utilizationCell(usedBytes, capacityBytes), enforcementCell(isHardened, enforcementLevel), fmt.Sprintf("%d", backupCount), formatOptionalTime(lastCheckedAt), }) } if rowsError := repositoryRows.Err(); rowsError != nil { return rowsError } overviewMetrics := []Metric{ KnownMetric("Repositories", float64(totalRepositories), UnitCount), KnownMetric("Nicht erreichbar", float64(unavailableCount), UnitCount), KnownMetric("Mit Löschschutz", float64(hardenedCount), UnitCount), KnownMetric("Belegt insgesamt", float64(totalUsedBytes), UnitBytes), } // Ohne hinterlegte Kapazitaet gibt es keine Auslastung. Sie aus der // belegten Menge zu schaetzen ergaebe immer hundert Prozent — die // bequemste Art, eine Warnung zu erzeugen, die nichts bedeutet. if totalCapacityBytes > 0 { overviewMetrics = append(overviewMetrics, KnownMetric("Kapazität insgesamt", float64(totalCapacityBytes), UnitBytes), KnownMetric("Auslastung insgesamt", float64(totalUsedBytes)*100/float64(totalCapacityBytes), UnitPercent)) } else { overviewMetrics = append(overviewMetrics, UnknownMetric("Kapazität insgesamt", UnitBytes, "Für kein Repository ist eine Kapazität hinterlegt."), UnknownMetric("Auslastung insgesamt", UnitPercent, "Ohne hinterlegte Kapazität lässt sich keine Auslastung bilden.")) } report.Sections = append(report.Sections, Section{ Title: "Überblick", Metrics: overviewMetrics, Tables: []Table{repositoryTable}, }) if withoutCapacityCount > 0 { report.AddNote("Für %d von %d Repositories ist keine Kapazität hinterlegt. Deren "+ "Auslastung lässt sich nicht bestimmen; sie fehlt in der Gesamtsumme, statt "+ "geschätzt zu werden.", withoutCapacityCount, totalRepositories) } if hardenedCount < totalRepositories { report.AddNote("%d von %d Repositories haben keinen gemessenen Löschschutz. Wer Zugriff "+ "auf die Dateien hat, kann dort Wiederherstellungspunkte entfernen.", totalRepositories-hardenedCount, totalRepositories) } return nil } // repositoryStatusLabel benennt den Zustand eines Repositories. // // Die vier Werte stammen aus dem Schema. Ein unbekannter wird durchgereicht // statt auf „unbekannt" abgebildet — sonst verschwaende ein Zustand, den eine // spaetere Migration ergaenzt hat, hinter einem freundlichen Wort. Genau dieser // Fehler steckte im Repository-Widget der Phase 12. func repositoryStatusLabel(rawStatus string) string { switch rawStatus { case "active": return "aktiv" case "read_only": return "nur lesbar" case "unavailable": return "NICHT ERREICHBAR" case "maintenance": return "Wartung" default: return rawStatus } } // formatOptionalBytes stellt eine Datenmenge dar, die fehlen kann. func formatOptionalBytes(byteCount *int64) string { if byteCount == nil { return "nicht hinterlegt" } return FormatBytes(float64(*byteCount)) } // utilizationCell bildet die Auslastung eines Repositories. func utilizationCell(usedBytes, capacityBytes *int64) string { if usedBytes == nil || capacityBytes == nil || *capacityBytes <= 0 { return "nicht bestimmbar" } return FormatPercent(float64(*usedBytes) * 100 / float64(*capacityBytes)) } // enforcementCell beschreibt den Loeschschutz eines Repositories. // // „Gehaertet" allein ist keine Aussage: Der Schutz wird gemessen, nicht // behauptet (Phase 11). Ohne Messung steht das hier auch so. func enforcementCell(isHardened bool, enforcementLevel *string) string { if !isHardened { return "keiner" } if enforcementLevel == nil || *enforcementLevel == "" { return "gehärtet, ungemessen" } return *enforcementLevel } // buildRecoveryReport beschreibt die Wiederherstellungen eines Zeitraums. // // Er ist die einzige Quelle **tatsaechlich gemessener** Wiederherstellungszeiten // der Anlage — und damit die Grundlage des RTO-Teils im Bericht ueber die // Wiederherstellungsziele. func (generator *Generator) buildRecoveryReport(buildContext context.Context, report *Report) error { const statisticsStatement = ` SELECT count(*), count(*) FILTER (WHERE status = 'succeeded'), count(*) FILTER (WHERE status = 'partial_failure'), count(*) FILTER (WHERE status = 'failed'), COALESCE(sum(bytes_restored), 0), COALESCE(sum(files_restored), 0), COALESCE(avg(EXTRACT(EPOCH FROM (completed_at - started_at))) FILTER (WHERE completed_at IS NOT NULL AND started_at IS NOT NULL), 0), COALESCE(max(EXTRACT(EPOCH FROM (completed_at - started_at))), 0), count(*) FILTER (WHERE completed_at IS NOT NULL AND started_at IS NOT NULL) FROM restore_jobs WHERE created_at >= $1 AND created_at < $2` var ( totalRestores, succeededRestores int64 partialRestores, failedRestores int64 bytesRestored, filesRestored int64 averageSeconds, longestSeconds float64 measuredRestores int64 ) scanError := generator.connectionPool.QueryRow(buildContext, statisticsStatement, report.PeriodFrom, report.PeriodTo).Scan( &totalRestores, &succeededRestores, &partialRestores, &failedRestores, &bytesRestored, &filesRestored, &averageSeconds, &longestSeconds, &measuredRestores) if scanError != nil { return fmt.Errorf("die kennzahlen der wiederherstellungen konnten nicht gelesen werden: %w", scanError) } overviewMetrics := []Metric{ KnownMetric("Wiederherstellungen", float64(totalRestores), UnitCount), KnownMetric("Erfolgreich", float64(succeededRestores), UnitCount), KnownMetric("Teilweise fehlgeschlagen", float64(partialRestores), UnitCount), KnownMetric("Gescheitert", float64(failedRestores), UnitCount), KnownMetric("Wiederhergestellte Datenmenge", float64(bytesRestored), UnitBytes), KnownMetric("Wiederhergestellte Objekte", float64(filesRestored), UnitCount), } if measuredRestores > 0 { overviewMetrics = append(overviewMetrics, KnownMetric("Mittlere Dauer", averageSeconds, UnitSeconds), KnownMetric("Längste Dauer", longestSeconds, UnitSeconds)) } else { overviewMetrics = append(overviewMetrics, UnknownMetric("Mittlere Dauer", UnitSeconds, "Im gewählten Zeitraum wurde keine Wiederherstellung abgeschlossen."), UnknownMetric("Längste Dauer", UnitSeconds, "Im gewählten Zeitraum wurde keine Wiederherstellung abgeschlossen.")) } report.Sections = append(report.Sections, Section{ Title: "Überblick", Description: "Alle Wiederherstellungen, die im gewählten Zeitraum angelegt wurden.", Metrics: overviewMetrics, }) if totalRestores == 0 { report.AddNote("Im gewählten Zeitraum fand keine Wiederherstellung statt. Damit gibt es " + "für diesen Zeitraum keine gemessene Wiederherstellungszeit — und ohne Messung " + "keine belastbare Aussage über die Wiederherstellungsdauer der Anlage.") } return generator.appendRestoreTable(buildContext, report) } // appendRestoreTable listet die Wiederherstellungen einzeln. func (generator *Generator) appendRestoreTable(buildContext context.Context, report *Report) error { const selectStatement = ` SELECT COALESCE(b.backup_id_in_repository, '—'), rj.status, rj.target_type, COALESCE(rj.target_ref, ''), rj.started_at, rj.completed_at, COALESCE(rj.bytes_restored, 0), COALESCE(rj.files_restored, 0), COALESCE(rj.files_skipped, 0), COALESCE(rj.error_code, ''), COALESCE(u.username, '') FROM restore_jobs rj LEFT JOIN backups b ON b.id = rj.backup_id LEFT JOIN users u ON u.id = rj.created_by WHERE rj.created_at >= $1 AND rj.created_at < $2 ORDER BY rj.created_at DESC LIMIT $3` restoreRows, queryError := generator.connectionPool.Query(buildContext, selectStatement, report.PeriodFrom, report.PeriodTo, maximumListedRuns) if queryError != nil { return fmt.Errorf("die wiederherstellungen konnten nicht gelesen werden: %w", queryError) } defer restoreRows.Close() restoreTable := Table{ Title: "Wiederherstellungen", Columns: []string{"Backup", "Ergebnis", "Ziel", "Pfad", "Beginn", "Dauer", "Datenmenge", "Objekte", "Übergangen", "Fehlercode", "Ausgelöst von"}, Rows: make([][]string, 0, 32), EmptyNotice: "Im gewählten Zeitraum wurde keine Wiederherstellung angelegt.", } for restoreRows.Next() { var ( backupName, restoreStatus, targetType string targetReference, errorCode, userName string startedAt, completedAt *time.Time bytesRestored, filesRestored int64 filesSkipped int64 ) if scanError := restoreRows.Scan(&backupName, &restoreStatus, &targetType, &targetReference, &startedAt, &completedAt, &bytesRestored, &filesRestored, &filesSkipped, &errorCode, &userName); scanError != nil { return fmt.Errorf("eine wiederherstellung konnte nicht gelesen werden: %w", scanError) } restoreTable.Rows = append(restoreTable.Rows, []string{ backupName, runStatusLabel(restoreStatus), targetType, targetReference, formatOptionalTime(startedAt), formatOptionalDuration(startedAt, completedAt), FormatBytes(float64(bytesRestored)), fmt.Sprintf("%d", filesRestored), fmt.Sprintf("%d", filesSkipped), errorCode, userName, }) } if rowsError := restoreRows.Err(); rowsError != nil { return rowsError } report.Sections = append(report.Sections, Section{ Title: "Im Einzelnen", Tables: []Table{restoreTable}, }) return nil }