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()) }