package jobs import ( "context" "fmt" "time" ) // DashboardWidget ist eine Kennzahl der Uebersicht. // // Der wichtigste Teil ist `Available`. Der Implementierungsplan nennt zehn // Widgets; drei davon haben in dieser Ausbaustufe keine Datengrundlage. Sie mit // einer erfundenen Zahl zu fuellen waere die bequeme Loesung und genau das, was // PROMPT.md §139 verbietet — ein Dashboard, das „0 kritische Meldungen" zeigt, // obwohl es gar keine Meldungen gibt, ist gefaehrlicher als eines, das die // Luecke benennt. type DashboardWidget struct { // Key ist der maschinenlesbare Bezeichner. Key string `json:"key"` // Title ist die Ueberschrift. Title string `json:"title"` // Available meldet, ob es dafuer eine Datengrundlage gibt. Available bool `json:"available"` // UnavailableReason erklaert eine fehlende Datengrundlage. // // Sie nennt, was fehlt — nicht „keine Daten", sondern welche Funktion es // noch nicht gibt und was stattdessen gilt. UnavailableReason string `json:"unavailable_reason,omitempty"` // Value ist die Hauptzahl. Value *float64 `json:"value,omitempty"` // Unit ist die Einheit der Hauptzahl. Unit string `json:"unit,omitempty"` // Detail erlaeutert die Zahl in einem Satz. Detail string `json:"detail,omitempty"` // Severity ist die Statusfarbe: healthy, warning, high, critical, information. // // Ausschliesslich fuer Status, nie zur Dekoration (PROMPT.md §28). Severity string `json:"severity"` // Breakdown sind ergaenzende Einzelwerte. Breakdown map[string]int64 `json:"breakdown,omitempty"` } // Statusfarben der Oberflaeche. const ( // SeverityHealthy meldet einen einwandfreien Zustand. SeverityHealthy = "healthy" // SeverityWarning meldet einen beobachtungswuerdigen Zustand. SeverityWarning = "warning" // SeverityHigh meldet einen ernsten Zustand. SeverityHigh = "high" // SeverityCritical meldet einen kritischen Zustand. SeverityCritical = "critical" // SeverityInformation meldet eine reine Auskunft. SeverityInformation = "information" ) // Dashboard ist die Uebersicht der Anlage. type Dashboard struct { // Widgets sind die Kennzahlen in Anzeigereihenfolge. Widgets []DashboardWidget `json:"widgets"` // AvailableCount ist die Zahl der Widgets mit Datengrundlage. AvailableCount int `json:"available_count"` // GeneratedAt ist der Zeitpunkt der Erhebung in UTC. GeneratedAt time.Time `json:"generated_at"` } // dashboardWindow ist der Zeitraum der Auswertung. // // Sieben Tage: Ein Tag zeigt bei taeglicher Sicherung nur einen Lauf je Auftrag // und schwankt dadurch zwischen 0 % und 100 %. Ein Monat verdeckt dagegen, dass // seit gestern nichts mehr geht. const dashboardWindow = 7 * 24 * time.Hour // Dashboard erhebt die Kennzahlen der Uebersicht. func (store *PostgresStore) Dashboard(dashboardContext context.Context) (*Dashboard, error) { dashboard := &Dashboard{ Widgets: make([]DashboardWidget, 0, 10), GeneratedAt: time.Now().UTC(), } widgetBuilders := []func(context.Context) (DashboardWidget, error){ store.buildProtectedSystemsWidget, store.buildSuccessRateWidget, store.buildFailedJobsWidget, store.buildStorageWidget, store.buildRecoveryAssuranceWidget, store.buildRepositoryHealthWidget, store.buildRecoveryPointObjectiveWidget, store.buildCriticalAlertsWidget, store.buildSecurityScoreWidget, } for _, buildWidget := range widgetBuilders { builtWidget, buildError := buildWidget(dashboardContext) if buildError != nil { return nil, buildError } dashboard.Widgets = append(dashboard.Widgets, builtWidget) } // Die drei Widgets ohne Datengrundlage stehen mit an derselben Stelle wie // die uebrigen — sie werden nicht weggelassen. Ein fehlendes Widget sieht // aus wie ein vergessenes; ein benanntes sagt, was die Anlage noch nicht kann. dashboard.Widgets = append(dashboard.Widgets, unavailableWidgets()...) for _, dashboardWidget := range dashboard.Widgets { if dashboardWidget.Available { dashboard.AvailableCount++ } } return dashboard, nil } // unavailableWidgets liefert die Kennzahlen ohne Datengrundlage. func unavailableWidgets() []DashboardWidget { return []DashboardWidget{ { Key: "capacity_forecast", Title: "Kapazitaetsprognose", Available: false, UnavailableReason: "Die Prognose braucht eine Verlaufsreihe des Speicherverbrauchs; " + "Kennzahlen werden erst ab Phase 13 fortgeschrieben.", Severity: SeverityInformation, }, } } // buildProtectedSystemsWidget zaehlt die gesicherten Systeme. func (store *PostgresStore) buildProtectedSystemsWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` SELECT (SELECT count(DISTINCT s.id) FROM backup_job_sources s JOIN backup_jobs j ON j.id = s.job_id WHERE j.deleted_at IS NULL), (SELECT count(*) FROM agents WHERE status = 'active'), (SELECT count(DISTINCT s.id) FROM backup_job_sources s JOIN backup_jobs j ON j.id = s.job_id WHERE j.deleted_at IS NULL AND j.status = 'active')` var ( sourceCount int64 agentCount int64 activeSources int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &sourceCount, &agentCount, &activeSources); scanError != nil { return DashboardWidget{}, fmt.Errorf("die geschuetzten systeme konnten nicht gezaehlt werden: %w", scanError) } widgetValue := float64(sourceCount) builtWidget := DashboardWidget{ Key: "protected_systems", Title: "Geschuetzte Systeme", Available: true, Value: &widgetValue, Unit: "Quellen", Severity: SeverityHealthy, Breakdown: map[string]int64{ "quellen_gesamt": sourceCount, "quellen_aktiv": activeSources, "agenten_aktiv": agentCount, "quellen_ruhend": sourceCount - activeSources, }, } switch { case sourceCount == 0: builtWidget.Severity = SeverityWarning builtWidget.Detail = "Es ist keine Quelle eingerichtet. Es wird nichts gesichert." case activeSources < sourceCount: // Eine ausgesetzte Sicherung faellt sonst niemandem auf: Es gibt keinen // Fehler zu sehen, nur ein Ausbleiben. builtWidget.Severity = SeverityWarning builtWidget.Detail = fmt.Sprintf("%d von %d Quellen gehoeren zu ausgesetzten Auftraegen.", sourceCount-activeSources, sourceCount) default: builtWidget.Detail = fmt.Sprintf("%d Quellen in aktiven Auftraegen, %d angemeldete Agenten.", activeSources, agentCount) } return builtWidget, nil } // buildSuccessRateWidget berechnet die Erfolgsquote der Sicherungen. func (store *PostgresStore) buildSuccessRateWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` SELECT count(*) FILTER (WHERE status = 'succeeded'), count(*) FILTER (WHERE status = 'partial_failure'), count(*) FILTER (WHERE status = 'failed'), count(*) FILTER (WHERE status IN ('succeeded', 'partial_failure', 'failed')) FROM backup_job_runs WHERE started_at > now() - $1::interval` var ( succeededCount int64 partialCount int64 failedCount int64 finishedCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement, dashboardWindow.String()).Scan(&succeededCount, &partialCount, &failedCount, &finishedCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("die erfolgsquote konnte nicht berechnet werden: %w", scanError) } builtWidget := DashboardWidget{ Key: "backup_success_rate", Title: "Erfolgsquote (7 Tage)", Available: true, Unit: "%", Breakdown: map[string]int64{ "erfolgreich": succeededCount, "teilfehler": partialCount, "gescheitert": failedCount, }, } if finishedCount == 0 { // Keine Laeufe heisst **nicht** 100 %. Ein Dashboard, das bei // ausgefallener Sicherung gruen zeigt, ist schlimmer als keines. builtWidget.Severity = SeverityWarning builtWidget.Detail = "In den letzten sieben Tagen lief keine Sicherung. " + "Es gibt keine Quote, die man berechnen koennte." return builtWidget, nil } // Ein Teilfehler zaehlt **nicht** als Erfolg (PROMPT.md §137). successPercentage := float64(succeededCount) * 100 / float64(finishedCount) builtWidget.Value = &successPercentage switch { case failedCount > 0: builtWidget.Severity = SeverityCritical case partialCount > 0: builtWidget.Severity = SeverityHigh default: builtWidget.Severity = SeverityHealthy } builtWidget.Detail = fmt.Sprintf("%d von %d Laeufen vollstaendig erfolgreich, %d Teilfehler, %d gescheitert.", succeededCount, finishedCount, partialCount, failedCount) return builtWidget, nil } // buildFailedJobsWidget zaehlt die Auftraege mit fehlgeschlagenem letzten Lauf. func (store *PostgresStore) buildFailedJobsWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` SELECT count(*) FILTER (WHERE last_outcome = 'failed'), count(*) FILTER (WHERE last_outcome = 'partial_failure'), count(*) FROM backup_jobs WHERE deleted_at IS NULL` var ( failedJobCount int64 partialJobCount int64 totalJobCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &failedJobCount, &partialJobCount, &totalJobCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("die gescheiterten auftraege konnten nicht gezaehlt werden: %w", scanError) } widgetValue := float64(failedJobCount + partialJobCount) builtWidget := DashboardWidget{ Key: "failed_jobs", Title: "Auftraege mit Befund", Available: true, Value: &widgetValue, Unit: "Auftraege", Severity: SeverityHealthy, Breakdown: map[string]int64{ "gescheitert": failedJobCount, "teilfehler": partialJobCount, "gesamt": totalJobCount, }, } switch { case failedJobCount > 0: builtWidget.Severity = SeverityCritical builtWidget.Detail = fmt.Sprintf("%d Auftraege sind zuletzt gescheitert, %d meldeten einen Teilfehler.", failedJobCount, partialJobCount) case partialJobCount > 0: builtWidget.Severity = SeverityHigh builtWidget.Detail = fmt.Sprintf("%d Auftraege meldeten zuletzt einen Teilfehler. "+ "Ein Teilfehler wird nicht wiederholt — er verlangt einen Blick.", partialJobCount) case totalJobCount == 0: builtWidget.Severity = SeverityWarning builtWidget.Detail = "Es ist kein Sicherungsauftrag eingerichtet." default: builtWidget.Detail = fmt.Sprintf("Alle %d Auftraege liefen zuletzt ohne Befund.", totalJobCount) } return builtWidget, nil } // buildStorageWidget erhebt die Speicherbelegung der Repositories. func (store *PostgresStore) buildStorageWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` SELECT COALESCE(sum(capacity_bytes), 0), COALESCE(sum(used_bytes), 0), count(*) FILTER (WHERE capacity_bytes IS NULL OR capacity_bytes = 0), count(*) FROM repositories` var ( capacityBytes int64 usedBytes int64 unknownCapacity int64 repositoryCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &capacityBytes, &usedBytes, &unknownCapacity, &repositoryCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("die speicherbelegung konnte nicht ermittelt werden: %w", scanError) } builtWidget := DashboardWidget{ Key: "storage", Title: "Speicherbelegung", Available: true, Unit: "%", Breakdown: map[string]int64{ "kapazitaet_bytes": capacityBytes, "belegt_bytes": usedBytes, "repositories": repositoryCount, }, } if repositoryCount == 0 { builtWidget.Severity = SeverityWarning builtWidget.Detail = "Es ist kein Repository eingerichtet." return builtWidget, nil } if capacityBytes == 0 { // Ohne bekannte Kapazitaet gibt es keinen Prozentsatz. Ihn zu schaetzen // waere eine erfundene Statistik. builtWidget.Severity = SeverityInformation builtWidget.Detail = fmt.Sprintf("Belegt sind %s. Die Gesamtkapazitaet ist fuer %d von %d "+ "Repositories nicht hinterlegt; ein Prozentsatz laesst sich daraus nicht bilden.", formatBytes(usedBytes), unknownCapacity, repositoryCount) return builtWidget, nil } usagePercentage := float64(usedBytes) * 100 / float64(capacityBytes) builtWidget.Value = &usagePercentage switch { case usagePercentage >= 90: builtWidget.Severity = SeverityCritical case usagePercentage >= 75: builtWidget.Severity = SeverityHigh case usagePercentage >= 60: builtWidget.Severity = SeverityWarning default: builtWidget.Severity = SeverityHealthy } builtWidget.Detail = fmt.Sprintf("%s von %s belegt.", formatBytes(usedBytes), formatBytes(capacityBytes)) if unknownCapacity > 0 { builtWidget.Detail += fmt.Sprintf(" Fuer %d Repositories ist keine Kapazitaet hinterlegt; "+ "sie fehlen in dieser Rechnung.", unknownCapacity) } return builtWidget, nil } // buildRecoveryAssuranceWidget erhebt die nachgewiesene Wiederherstellbarkeit. func (store *PostgresStore) buildRecoveryAssuranceWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` SELECT count(*) FILTER (WHERE classification = 'recoverable'), count(*) FILTER (WHERE classification = 'verified'), count(*) FILTER (WHERE classification = 'corrupted'), count(*) FILTER (WHERE classification IS NULL OR classification = 'successful'), count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL` var ( recoverableCount int64 verifiedCount int64 corruptedCount int64 unverifiedCount int64 totalCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &recoverableCount, &verifiedCount, &corruptedCount, &unverifiedCount, &totalCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("die recovery assurance konnte nicht erhoben werden: %w", scanError) } builtWidget := DashboardWidget{ Key: "recovery_assurance", Title: "Nachgewiesen wiederherstellbar", Available: true, Unit: "%", Breakdown: map[string]int64{ "wiederherstellbar": recoverableCount, "geprueft": verifiedCount, "beschaedigt": corruptedCount, "ungeprueft": unverifiedCount, }, } if totalCount == 0 { builtWidget.Severity = SeverityWarning builtWidget.Detail = "Es gibt keinen Wiederherstellungspunkt." return builtWidget, nil } // Gezaehlt wird nur, was einen bestandenen Wiederherstellungstest hat. Eine // bestandene Blockpruefung ist ein Indiz, kein Nachweis (Phase 10). provenPercentage := float64(recoverableCount) * 100 / float64(totalCount) builtWidget.Value = &provenPercentage switch { case corruptedCount > 0: builtWidget.Severity = SeverityCritical builtWidget.Detail = fmt.Sprintf("%d Wiederherstellungspunkte sind beschaedigt. "+ "%d von %d sind nachweislich wiederherstellbar.", corruptedCount, recoverableCount, totalCount) case recoverableCount == 0: builtWidget.Severity = SeverityHigh builtWidget.Detail = fmt.Sprintf("Kein Wiederherstellungspunkt wurde je zurueckgeschrieben. "+ "%d sind geprueft, %d ungeprueft — beides sind Indizien, kein Nachweis.", verifiedCount, unverifiedCount) case provenPercentage < 50: builtWidget.Severity = SeverityWarning builtWidget.Detail = fmt.Sprintf("%d von %d Punkten wurden zurueckgeschrieben und verglichen.", recoverableCount, totalCount) default: builtWidget.Severity = SeverityHealthy builtWidget.Detail = fmt.Sprintf("%d von %d Punkten wurden zurueckgeschrieben und verglichen.", recoverableCount, totalCount) } return builtWidget, nil } // buildRepositoryHealthWidget erhebt den Zustand der Repositories. func (store *PostgresStore) buildRepositoryHealthWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` -- Die Zustaende stammen aus dem CHECK der Tabelle: active, read_only, -- unavailable, maintenance. Frueher stand hier 'offline' und 'degraded' — -- Zustaende, die es im Schema nie gab. Die Zaehler blieben dadurch -- dauerhaft null, und das Widget meldete jedes Repository als -- einwandfrei, auch ein nicht erreichbares. SELECT count(*) FILTER (WHERE status = 'active'), count(*) FILTER (WHERE status IN ('read_only', 'maintenance')), count(*) FILTER (WHERE status = 'unavailable'), count(*) FILTER (WHERE enforcement_level = 'filesystem'), count(*) FILTER (WHERE hardened = true), count(*) FROM repositories` var ( activeCount int64 restrictedCount int64 unavailableCount int64 enforcedCount int64 hardenedCount int64 repositoryCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &activeCount, &restrictedCount, &unavailableCount, &enforcedCount, &hardenedCount, &repositoryCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("der repository-zustand konnte nicht erhoben werden: %w", scanError) } widgetValue := float64(activeCount) builtWidget := DashboardWidget{ Key: "repository_health", Title: "Repositories", Available: true, Value: &widgetValue, Unit: "einwandfrei", Breakdown: map[string]int64{ "aktiv": activeCount, "eingeschraenkt": restrictedCount, "nicht_erreichbar": unavailableCount, "gehaertet": hardenedCount, "loeschschutz_gemessen": enforcedCount, }, } switch { case repositoryCount == 0: builtWidget.Severity = SeverityWarning builtWidget.Detail = "Es ist kein Repository eingerichtet." case unavailableCount > 0: builtWidget.Severity = SeverityCritical builtWidget.Detail = fmt.Sprintf("%d Repositories sind nicht erreichbar.", unavailableCount) case restrictedCount > 0: builtWidget.Severity = SeverityHigh builtWidget.Detail = fmt.Sprintf("%d Repositories nehmen keine Sicherungen an "+ "(nur lesend oder in Wartung).", restrictedCount) case hardenedCount > 0 && enforcedCount < hardenedCount: // Ein gehaertetes Repository, dessen Loeschschutz nie gemessen wurde, // ist eine Behauptung. Genau darauf verzichtet Phase 11. builtWidget.Severity = SeverityWarning builtWidget.Detail = fmt.Sprintf("Alle %d Repositories sind erreichbar. Bei %d als gehaertet "+ "gefuehrten wurde der Loeschschutz nicht gemessen.", repositoryCount, hardenedCount-enforcedCount) default: builtWidget.Severity = SeverityHealthy builtWidget.Detail = fmt.Sprintf("Alle %d Repositories sind erreichbar.", repositoryCount) } return builtWidget, nil } // buildRecoveryPointObjectiveWidget prueft die Einhaltung der Wiederherstellungspunkte. func (store *PostgresStore) buildRecoveryPointObjectiveWidget(buildContext context.Context) (DashboardWidget, error) { // Verglichen wird der juengste abgeschlossene Lauf je Auftrag gegen dessen // RPO. Auftraege ohne RPO bleiben aussen vor — eine Vorgabe, die niemand // gemacht hat, kann niemand verletzen. const countStatement = ` WITH letzte_laeufe AS ( SELECT j.id AS job_id, j.rpo_seconds, max(r.completed_at) FILTER (WHERE r.status IN ('succeeded', 'partial_failure')) AS letzter_erfolg FROM backup_jobs j LEFT JOIN backup_job_runs r ON r.job_id = j.id WHERE j.deleted_at IS NULL AND j.rpo_seconds > 0 GROUP BY j.id, j.rpo_seconds ) SELECT count(*) FILTER (WHERE letzter_erfolg IS NOT NULL AND letzter_erfolg > now() - (rpo_seconds || ' seconds')::interval), count(*) FILTER (WHERE letzter_erfolg IS NULL OR letzter_erfolg <= now() - (rpo_seconds || ' seconds')::interval), count(*) FROM letzte_laeufe` var ( compliantCount int64 violatingCount int64 measuredCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &compliantCount, &violatingCount, &measuredCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("die rpo-einhaltung konnte nicht erhoben werden: %w", scanError) } builtWidget := DashboardWidget{ Key: "rpo_compliance", Title: "Wiederherstellungspunkt eingehalten", Available: true, Unit: "%", Breakdown: map[string]int64{ "eingehalten": compliantCount, "verletzt": violatingCount, "mit_vorgabe": measuredCount, }, } if measuredCount == 0 { builtWidget.Severity = SeverityInformation builtWidget.Detail = "Kein Auftrag hat eine RPO-Vorgabe. Ohne Vorgabe gibt es nichts einzuhalten." return builtWidget, nil } compliancePercentage := float64(compliantCount) * 100 / float64(measuredCount) builtWidget.Value = &compliancePercentage switch { case violatingCount > 0: builtWidget.Severity = SeverityCritical builtWidget.Detail = fmt.Sprintf("%d von %d Auftraegen verletzen ihre RPO-Vorgabe: "+ "Die letzte erfolgreiche Sicherung liegt laenger zurueck als zugesagt.", violatingCount, measuredCount) default: builtWidget.Severity = SeverityHealthy builtWidget.Detail = fmt.Sprintf("Alle %d Auftraege mit Vorgabe halten sie ein.", measuredCount) } return builtWidget, nil } // byteUnitStep ist der Faktor zwischen zwei Groesseneinheiten. const byteUnitStep = 1024 // formatBytes schreibt eine Datenmenge lesbar. func formatBytes(byteCount int64) string { if byteCount < byteUnitStep { return fmt.Sprintf("%d B", byteCount) } remainingValue := float64(byteCount) unitNames := []string{"KiB", "MiB", "GiB", "TiB", "PiB"} chosenUnit := unitNames[0] for _, unitName := range unitNames { remainingValue /= byteUnitStep chosenUnit = unitName if remainingValue < byteUnitStep { break } } return fmt.Sprintf("%.1f %s", remainingValue, chosenUnit) } // buildCriticalAlertsWidget erhebt die unerledigten Meldungen. // // Das Widget stand bis Phase 14 als „nicht verfuegbar" mit der Begruendung, eine // Null hiesse „keine Probleme" und wuerde bedeuten „es wird nicht geprueft". // Jetzt gibt es die Pruefung — und die Null bedeutet tatsaechlich, dass zehn // Regeln nichts gefunden haben. func (store *PostgresStore) buildCriticalAlertsWidget(buildContext context.Context) (DashboardWidget, error) { const countStatement = ` SELECT count(*) FILTER (WHERE status IN ('open', 'acknowledged') AND severity = 'critical'), count(*) FILTER (WHERE status IN ('open', 'acknowledged') AND severity = 'high'), count(*) FILTER (WHERE status = 'open'), count(*) FILTER (WHERE status = 'acknowledged') FROM alerts` var ( criticalCount int64 highCount int64 openCount int64 acknowledgedCount int64 ) if scanError := store.connectionPool.QueryRow(buildContext, countStatement).Scan( &criticalCount, &highCount, &openCount, &acknowledgedCount); scanError != nil { return DashboardWidget{}, fmt.Errorf("die meldungslage konnte nicht erhoben werden: %w", scanError) } widgetValue := float64(criticalCount) builtWidget := DashboardWidget{ Key: "critical_alerts", Title: "Kritische Meldungen", Available: true, Value: &widgetValue, Unit: "offen", Severity: SeverityHealthy, Breakdown: map[string]int64{ "kritisch": criticalCount, "ernst": highCount, "unbearbeitet": openCount, "zur_kenntnis_genommen": acknowledgedCount, }, } switch { case criticalCount > 0: builtWidget.Severity = SeverityCritical builtWidget.Detail = fmt.Sprintf("%d kritische und %d ernste Meldungen sind unerledigt.", criticalCount, highCount) case highCount > 0: builtWidget.Severity = SeverityHigh builtWidget.Detail = fmt.Sprintf("%d ernste Meldungen sind unerledigt.", highCount) case openCount > 0 || acknowledgedCount > 0: builtWidget.Severity = SeverityWarning builtWidget.Detail = fmt.Sprintf("%d Meldungen sind unerledigt, keine davon kritisch.", openCount+acknowledgedCount) default: builtWidget.Detail = "Keine unerledigten Meldungen. Zehn Regeln pruefen fortlaufend." } return builtWidget, nil } // buildSecurityScoreWidget zeigt die zuletzt erfasste Sicherheitsbewertung. // // Gelesen wird die **Messung** aus der Kennzahlenerfassung, nicht der Score // selbst neu berechnet. Zwei Gründe: Die Übersicht bliebe sonst an zehn // Datenbankabfragen hängen, und dieses Paket bekäme eine Abhängigkeit auf das // Security Center, die es sonst nicht braucht. // // Der Preis ist, dass die Zahl bis zu fünf Minuten alt sein kann. Das Alter // steht deshalb dabei. func (store *PostgresStore) buildSecurityScoreWidget(buildContext context.Context) (DashboardWidget, error) { const scoreStatement = ` SELECT value, measured_at FROM metric_samples WHERE metric_name = 'security_score' ORDER BY measured_at DESC LIMIT 1` var ( scorePercentage float64 measuredAt time.Time ) scanError := store.connectionPool.QueryRow(buildContext, scoreStatement).Scan( &scorePercentage, &measuredAt) if scanError != nil { // Noch keine Messung: Das ist kein Fehler, sondern der Zustand kurz nach // dem Start. Eine Null wäre hier die schlechteste aller Antworten — sie // läse sich wie „völlig unsicher". return DashboardWidget{ Key: "security_score", Title: "Sicherheitsbewertung", Available: true, Severity: SeverityInformation, Detail: "Es liegt noch keine Erhebung vor. Die Kennzahlenerfassung schreibt sie " + "beim nächsten Durchgang fort; GET /api/v1/security liefert sie sofort.", }, nil } builtWidget := DashboardWidget{ Key: "security_score", Title: "Sicherheitsbewertung", Available: true, Value: &scorePercentage, Unit: "%", Breakdown: map[string]int64{"alter_minuten": int64(time.Since(measuredAt).Minutes())}, } switch { case scorePercentage >= 90: builtWidget.Severity = SeverityHealthy case scorePercentage >= 70: builtWidget.Severity = SeverityWarning case scorePercentage >= 50: builtWidget.Severity = SeverityHigh default: builtWidget.Severity = SeverityCritical } builtWidget.Detail = fmt.Sprintf("Erhoben vor %d Minuten. Die Befunde stehen unter "+ "GET /api/v1/security/findings.", int(time.Since(measuredAt).Minutes())) return builtWidget, nil }