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>
757 lines
26 KiB
Go
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
|
|
}
|