syncova-backup/packages/jobs/dashboard.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

757 lines
26 KiB
Go

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
}