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>
288 lines
8.7 KiB
Go
288 lines
8.7 KiB
Go
package reports
|
||
|
||
import "io"
|
||
|
||
// Schriftgrade des Berichts.
|
||
const (
|
||
// titleFontSize ist der Grad der Berichtsueberschrift.
|
||
titleFontSize = 18.0
|
||
// sectionFontSize ist der Grad einer Abschnittsueberschrift.
|
||
sectionFontSize = 13.0
|
||
// bodyFontSize ist der Grad des Fliesstexts.
|
||
bodyFontSize = 9.5
|
||
// tableFontSize ist der Grad in Tabellen.
|
||
//
|
||
// Kleiner als der Fliesstext: Eine Tabelle mit sechs Spalten passt sonst
|
||
// nicht auf A4, und eine querformatige Seite mitten im Bericht ist
|
||
// unangenehmer als eine halbe Stufe kleinere Schrift.
|
||
tableFontSize = 8.0
|
||
)
|
||
|
||
// WritePDF schreibt einen Bericht als PDF.
|
||
func WritePDF(outputWriter io.Writer, report *Report) error {
|
||
document := newPDFDocument()
|
||
|
||
writePDFHeader(document, report)
|
||
|
||
for _, section := range report.Sections {
|
||
writePDFSection(document, section)
|
||
}
|
||
|
||
writePDFNotes(document, report)
|
||
|
||
_, writeError := outputWriter.Write(document.bytes())
|
||
|
||
return writeError
|
||
}
|
||
|
||
// writePDFHeader schreibt Titel und Kopfangaben.
|
||
func writePDFHeader(document *pdfDocument, report *Report) {
|
||
document.drawText(report.Title, fontBold, titleFontSize, 0)
|
||
document.addSpace(2)
|
||
|
||
for _, descriptionLine := range wrapText(report.Description, fontRegular, bodyFontSize, contentWidth) {
|
||
document.drawText(descriptionLine, fontRegular, bodyFontSize, 0)
|
||
}
|
||
|
||
document.addSpace(4)
|
||
|
||
// Ein Zustandsbericht bekommt keinen Zeitraum angezeigt. Ihn mit zwei
|
||
// gleichen Zeitpunkten auszuweisen sähe nach einem Fehler aus.
|
||
if report.PeriodFrom.Before(report.PeriodTo) {
|
||
document.drawText("Zeitraum: "+FormatTimestamp(report.PeriodFrom)+" bis "+
|
||
FormatTimestamp(report.PeriodTo), fontRegular, bodyFontSize, 0)
|
||
} else {
|
||
document.drawText("Zustand zum "+FormatTimestamp(report.PeriodTo),
|
||
fontRegular, bodyFontSize, 0)
|
||
}
|
||
|
||
generationLine := "Erzeugt am " + FormatTimestamp(report.GeneratedAt)
|
||
if report.GeneratedBy != "" {
|
||
generationLine += " von " + report.GeneratedBy
|
||
}
|
||
|
||
document.drawText(generationLine, fontRegular, bodyFontSize, 0)
|
||
document.addSpace(4)
|
||
document.drawRule(1, 0.3)
|
||
}
|
||
|
||
// writePDFSection schreibt einen Abschnitt.
|
||
func writePDFSection(document *pdfDocument, section Section) {
|
||
document.addSpace(8)
|
||
document.drawText(section.Title, fontBold, sectionFontSize, 0)
|
||
|
||
if section.Description != "" {
|
||
for _, descriptionLine := range wrapText(section.Description, fontRegular, bodyFontSize, contentWidth) {
|
||
document.drawText(descriptionLine, fontRegular, bodyFontSize, 0)
|
||
}
|
||
}
|
||
|
||
document.addSpace(4)
|
||
|
||
writePDFMetrics(document, section.Metrics)
|
||
|
||
for _, table := range section.Tables {
|
||
writePDFTable(document, table)
|
||
}
|
||
}
|
||
|
||
// writePDFMetrics schreibt die Kennzahlen eines Abschnitts.
|
||
//
|
||
// Die Beschriftung steht links, der Wert an einer festen Spalte rechts davon.
|
||
// Eine ungemessene Kennzahl zeigt „nicht gemessen" und darunter den Grund —
|
||
// nicht eine Null, die im Bericht wie eine Messung aussaehe.
|
||
func writePDFMetrics(document *pdfDocument, metrics []Metric) {
|
||
const valueColumnOffset = 260.0
|
||
|
||
for _, metric := range metrics {
|
||
document.ensureSpace(bodyFontSize + 4)
|
||
|
||
labelY := document.cursorY
|
||
|
||
document.drawText(truncateToWidth(metric.Label, fontRegular, bodyFontSize, valueColumnOffset-8),
|
||
fontRegular, bodyFontSize, 0)
|
||
|
||
// Der Wert gehoert auf dieselbe Zeile; drawText hat den Zeiger schon
|
||
// weitergesetzt, also wird er kurz zurueckgestellt.
|
||
valueY := document.cursorY
|
||
document.cursorY = labelY
|
||
document.drawText(metric.Display(), fontBold, bodyFontSize, valueColumnOffset)
|
||
document.cursorY = valueY
|
||
|
||
if !metric.IsKnown && metric.UnknownReason != "" {
|
||
for _, reasonLine := range wrapText(metric.UnknownReason, fontRegular, bodyFontSize-1,
|
||
contentWidth-valueColumnOffset) {
|
||
document.drawText(reasonLine, fontRegular, bodyFontSize-1, valueColumnOffset)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// writePDFTable schreibt eine Tabelle.
|
||
func writePDFTable(document *pdfDocument, table Table) {
|
||
document.addSpace(6)
|
||
document.drawText(table.Title, fontBold, bodyFontSize+0.5, 0)
|
||
document.addSpace(2)
|
||
|
||
if len(table.Rows) == 0 {
|
||
document.drawText(emptyNoticeOf(table), fontRegular, bodyFontSize, 8)
|
||
|
||
return
|
||
}
|
||
|
||
columnOffsets, columnWidths := measureColumns(table)
|
||
|
||
document.ensureSpace(3 * (tableFontSize + 3))
|
||
|
||
headerY := document.cursorY
|
||
|
||
for columnIndex, columnLabel := range table.Columns {
|
||
document.cursorY = headerY
|
||
document.drawText(truncateToWidth(columnLabel, fontBold, tableFontSize, columnWidths[columnIndex]),
|
||
fontBold, tableFontSize, columnOffsets[columnIndex])
|
||
}
|
||
|
||
document.cursorY = headerY - tableFontSize - 3
|
||
document.drawRule(0.5, 0.6)
|
||
|
||
for _, tableRow := range table.Rows {
|
||
document.ensureSpace(tableFontSize + 4)
|
||
|
||
rowY := document.cursorY
|
||
|
||
for columnIndex := range table.Columns {
|
||
cellText := ""
|
||
if columnIndex < len(tableRow) {
|
||
cellText = tableRow[columnIndex]
|
||
}
|
||
|
||
document.cursorY = rowY
|
||
document.drawText(truncateToWidth(cellText, fontRegular, tableFontSize, columnWidths[columnIndex]),
|
||
fontRegular, tableFontSize, columnOffsets[columnIndex])
|
||
}
|
||
|
||
document.cursorY = rowY - tableFontSize - 3
|
||
}
|
||
}
|
||
|
||
// measureColumns bestimmt Position und Breite jeder Spalte.
|
||
//
|
||
// Gemessen wird der breiteste tatsaechliche Inhalt, nicht die Spaltenzahl:
|
||
// Gleich breite Spalten verschenken den Platz an eine Datumsspalte und nehmen
|
||
// ihn dem Pfad, der die eigentliche Auskunft traegt.
|
||
//
|
||
// Passt die Summe nicht auf die Seite, geben **nur die breitesten Spalten** ab.
|
||
// Ein gleichmaessiger Stauchfaktor waere der naheliegende Weg und der falsche:
|
||
// Er kuerzte in der Probe „TEILWEISE FEHLGESCHLAGEN" zu „TEILWEISE FEHL…",
|
||
// weil daneben ein langer Pfad stand — ausgerechnet der Wert, wegen dessen man
|
||
// den Bericht liest. Gesucht wird deshalb eine gemeinsame Obergrenze: Spalten
|
||
// darunter bleiben unangetastet, alles darueber wird auf sie gekappt.
|
||
func measureColumns(table Table) ([]float64, []float64) {
|
||
const columnGap = 8.0
|
||
|
||
columnCount := len(table.Columns)
|
||
naturalWidths := make([]float64, columnCount)
|
||
|
||
for columnIndex, columnLabel := range table.Columns {
|
||
naturalWidths[columnIndex] = textWidth(columnLabel, fontBold, tableFontSize)
|
||
}
|
||
|
||
for _, tableRow := range table.Rows {
|
||
for columnIndex := 0; columnIndex < columnCount && columnIndex < len(tableRow); columnIndex++ {
|
||
cellWidth := textWidth(tableRow[columnIndex], fontRegular, tableFontSize)
|
||
|
||
if cellWidth > naturalWidths[columnIndex] {
|
||
naturalWidths[columnIndex] = cellWidth
|
||
}
|
||
}
|
||
}
|
||
|
||
totalWidth := 0.0
|
||
for _, naturalWidth := range naturalWidths {
|
||
totalWidth += naturalWidth
|
||
}
|
||
|
||
availableWidth := contentWidth - float64(columnCount-1)*columnGap
|
||
|
||
widthCap := findWidthCap(naturalWidths, availableWidth, totalWidth)
|
||
|
||
columnOffsets := make([]float64, columnCount)
|
||
columnWidths := make([]float64, columnCount)
|
||
currentOffset := 0.0
|
||
|
||
for columnIndex := range columnCount {
|
||
columnWidths[columnIndex] = min(naturalWidths[columnIndex], widthCap)
|
||
columnOffsets[columnIndex] = currentOffset
|
||
currentOffset += columnWidths[columnIndex] + columnGap
|
||
}
|
||
|
||
return columnOffsets, columnWidths
|
||
}
|
||
|
||
// findWidthCap sucht die Obergrenze, bei der alle Spalten auf die Seite passen.
|
||
//
|
||
// Die Spalten werden aufsteigend durchlaufen: Bleibt fuer die verbleibenden im
|
||
// Schnitt mehr Platz uebrig, als die aktuelle braucht, bekommt sie ihre volle
|
||
// Breite und gibt den Rest weiter. Andernfalls teilen sich alle uebrigen den
|
||
// Rest zu gleichen Teilen — das ist die gesuchte Grenze.
|
||
func findWidthCap(naturalWidths []float64, availableWidth float64, totalWidth float64) float64 {
|
||
if totalWidth <= availableWidth {
|
||
return availableWidth
|
||
}
|
||
|
||
sortedWidths := make([]float64, len(naturalWidths))
|
||
copy(sortedWidths, naturalWidths)
|
||
|
||
for outerIndex := 1; outerIndex < len(sortedWidths); outerIndex++ {
|
||
currentWidth := sortedWidths[outerIndex]
|
||
innerIndex := outerIndex - 1
|
||
|
||
for innerIndex >= 0 && sortedWidths[innerIndex] > currentWidth {
|
||
sortedWidths[innerIndex+1] = sortedWidths[innerIndex]
|
||
innerIndex--
|
||
}
|
||
|
||
sortedWidths[innerIndex+1] = currentWidth
|
||
}
|
||
|
||
remainingWidth := availableWidth
|
||
|
||
for widthIndex, sortedWidth := range sortedWidths {
|
||
remainingColumns := float64(len(sortedWidths) - widthIndex)
|
||
equalShare := remainingWidth / remainingColumns
|
||
|
||
if sortedWidth > equalShare {
|
||
return equalShare
|
||
}
|
||
|
||
remainingWidth -= sortedWidth
|
||
}
|
||
|
||
return availableWidth
|
||
}
|
||
|
||
// writePDFNotes schreibt die Hinweise ans Ende.
|
||
func writePDFNotes(document *pdfDocument, report *Report) {
|
||
if len(report.Notes) == 0 {
|
||
return
|
||
}
|
||
|
||
document.addSpace(10)
|
||
document.drawRule(0.5, 0.6)
|
||
document.drawText("Hinweise", fontBold, bodyFontSize+1, 0)
|
||
document.addSpace(2)
|
||
|
||
for _, note := range report.Notes {
|
||
noteLines := wrapText(note, fontRegular, bodyFontSize-0.5, contentWidth-12)
|
||
|
||
for lineIndex, noteLine := range noteLines {
|
||
linePrefix := " "
|
||
if lineIndex == 0 {
|
||
linePrefix = "– "
|
||
}
|
||
|
||
document.drawText(linePrefix+noteLine, fontRegular, bodyFontSize-0.5, 0)
|
||
}
|
||
}
|
||
}
|