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>
524 lines
17 KiB
TypeScript
524 lines
17 KiB
TypeScript
/**
|
|
* Berichte.
|
|
*
|
|
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand.
|
|
*
|
|
* Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske:
|
|
*
|
|
* Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV
|
|
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
|
|
* eine ungemessene Kennzahl als solche dar, nicht als Null. Genau dieser
|
|
* Unterschied entscheidet, ob ein Bericht die Wahrheit sagt: „Erfolgsquote 0 %"
|
|
* meldet eine ausgefallene Sicherung, „nicht bestimmbar" meldet, dass es nichts
|
|
* zu bewerten gab.
|
|
*/
|
|
|
|
import { useCallback, useState } from 'react';
|
|
import { ApiError, downloadApiFile, requestApi } from '../../api/client';
|
|
import { useApiResource } from '../../api/useApiResource';
|
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
|
|
|
/** Art des Zeitbezugs eines Berichts. */
|
|
type PeriodKind = 'range' | 'point_in_time';
|
|
|
|
/** Ausgabeformat. */
|
|
type ReportFormat = 'json' | 'csv' | 'pdf';
|
|
|
|
/** Eintrag des Berichtskatalogs. */
|
|
interface ReportCatalogEntry {
|
|
/** Maschinenlesbarer Bezeichner. */
|
|
readonly type: string;
|
|
/** Bezeichnung. */
|
|
readonly title: string;
|
|
/** Erklaert, welche Frage der Bericht beantwortet. */
|
|
readonly description: string;
|
|
/** Art des Zeitbezugs. */
|
|
readonly period_kind: PeriodKind;
|
|
/** Beschreibung des Standardzeitraums. */
|
|
readonly default_period?: string;
|
|
/** Verfuegbare Ausgabeformate. */
|
|
readonly formats: readonly ReportFormat[];
|
|
}
|
|
|
|
/** Eine Kennzahl eines Berichts. */
|
|
interface ReportMetric {
|
|
/** Beschriftung. */
|
|
readonly label: string;
|
|
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */
|
|
readonly value?: number;
|
|
/** Einheit. */
|
|
readonly unit?: string;
|
|
/** Nicht numerische Angabe. */
|
|
readonly text?: string;
|
|
/** Meldet, ob der Wert gemessen wurde. */
|
|
readonly is_known: boolean;
|
|
/** Erklaert einen fehlenden Wert. */
|
|
readonly unknown_reason?: string;
|
|
}
|
|
|
|
/** Eine Tabelle eines Berichts. */
|
|
interface ReportTable {
|
|
/** Ueberschrift. */
|
|
readonly title: string;
|
|
/** Spaltenbeschriftungen. */
|
|
readonly columns: readonly string[];
|
|
/** Zeilen. */
|
|
readonly rows: readonly (readonly string[])[];
|
|
/** Hinweis anstelle einer leeren Tabelle. */
|
|
readonly empty_notice?: string;
|
|
}
|
|
|
|
/** Ein Abschnitt eines Berichts. */
|
|
interface ReportSection {
|
|
/** Ueberschrift. */
|
|
readonly title: string;
|
|
/** Erklaerung. */
|
|
readonly description?: string;
|
|
/** Kennzahlen. */
|
|
readonly metrics?: readonly ReportMetric[];
|
|
/** Tabellen. */
|
|
readonly tables?: readonly ReportTable[];
|
|
}
|
|
|
|
/** Ein fertiger Bericht. */
|
|
interface GeneratedReport {
|
|
/** Reportart. */
|
|
readonly type: string;
|
|
/** Bezeichnung. */
|
|
readonly title: string;
|
|
/** Erklaerung. */
|
|
readonly description: string;
|
|
/** Beginn des Zeitraums. */
|
|
readonly period_from: string;
|
|
/** Ende des Zeitraums. */
|
|
readonly period_to: string;
|
|
/** Erzeugungszeitpunkt. */
|
|
readonly generated_at: string;
|
|
/** Anmeldename des Anfordernden. */
|
|
readonly generated_by?: string;
|
|
/** Abschnitte. */
|
|
readonly sections: readonly ReportSection[];
|
|
/** Benannte Luecken und Einschraenkungen. */
|
|
readonly notes?: readonly string[];
|
|
}
|
|
|
|
/** Zustand einer laufenden Ausgabe. */
|
|
interface ExportState {
|
|
/** Format, das gerade erzeugt wird; null, wenn nichts laeuft. */
|
|
readonly runningFormat: ReportFormat | null;
|
|
/** Fehler der letzten Ausgabe. */
|
|
readonly exportError: ApiError | null;
|
|
}
|
|
|
|
/** Laedt den Berichtskatalog. */
|
|
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
|
|
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
|
|
}
|
|
|
|
/** Die Berichtsseite. */
|
|
export function ReportsPage(): React.JSX.Element {
|
|
const catalogResource = useApiResource<readonly ReportCatalogEntry[]>(loadReportCatalog);
|
|
|
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
|
const [previewReport, setPreviewReport] = useState<GeneratedReport | null>(null);
|
|
const [exportState, setExportState] = useState<ExportState>({
|
|
runningFormat: null,
|
|
exportError: null,
|
|
});
|
|
|
|
const selectedEntry =
|
|
catalogResource.data?.find((entry) => entry.type === selectedType) ?? null;
|
|
|
|
/**
|
|
* Fordert einen Bericht an.
|
|
*
|
|
* JSON wandert in die Vorschau, CSV und PDF in den Download-Ordner. Beide Wege
|
|
* erzeugen **denselben** Bericht — die Vorschau ist keine eigene Ansicht der
|
|
* Daten, sondern dieselbe Struktur in anderer Darstellung.
|
|
*/
|
|
const generateReport = useCallback(
|
|
async (reportType: string, outputFormat: ReportFormat): Promise<void> => {
|
|
setExportState({ runningFormat: outputFormat, exportError: null });
|
|
|
|
try {
|
|
if (outputFormat === 'json') {
|
|
const generatedReport = await requestApi<GeneratedReport>('/reports/generate', {
|
|
method: 'POST',
|
|
body: { type: reportType, format: 'json' },
|
|
});
|
|
|
|
setPreviewReport(generatedReport);
|
|
setExportState({ runningFormat: null, exportError: null });
|
|
|
|
return;
|
|
}
|
|
|
|
const downloadedFile = await downloadApiFile('/reports/generate', {
|
|
method: 'POST',
|
|
body: { type: reportType, format: outputFormat },
|
|
});
|
|
|
|
triggerBrowserDownload(downloadedFile.blob, downloadedFile.fileName || `bericht.${outputFormat}`);
|
|
setExportState({ runningFormat: null, exportError: null });
|
|
} catch (caughtError) {
|
|
setExportState({
|
|
runningFormat: null,
|
|
exportError: caughtError instanceof ApiError ? caughtError : null,
|
|
});
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const selectReport = useCallback(
|
|
(reportType: string): void => {
|
|
setSelectedType(reportType);
|
|
setPreviewReport(null);
|
|
setExportState({ runningFormat: null, exportError: null });
|
|
|
|
void generateReport(reportType, 'json');
|
|
},
|
|
[generateReport],
|
|
);
|
|
|
|
if (catalogResource.loadState === 'loading') {
|
|
return <LoadingState what="die Berichte" />;
|
|
}
|
|
|
|
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
|
|
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
|
|
// blieb — dann steht der Katalog trotzdem nicht zur Verfuegung.
|
|
return catalogResource.loadError === null ? (
|
|
<p className="page__state">Der Berichtskatalog steht nicht zur Verfügung.</p>
|
|
) : (
|
|
<ErrorState error={catalogResource.loadError} onRetry={catalogResource.reload} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="page">
|
|
<header className="page__header">
|
|
<h1 className="page__title">Berichte</h1>
|
|
<p className="page__subtitle">
|
|
Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei —
|
|
einschließlich der Angaben, die sich nicht messen ließen.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="reports__catalog">
|
|
{catalogResource.data.map((catalogEntry) => (
|
|
<button
|
|
key={catalogEntry.type}
|
|
type="button"
|
|
className={
|
|
catalogEntry.type === selectedType
|
|
? 'reports__entry reports__entry--selected'
|
|
: 'reports__entry'
|
|
}
|
|
onClick={() => selectReport(catalogEntry.type)}
|
|
>
|
|
<span className="reports__entry-title">{catalogEntry.title}</span>
|
|
<span className="reports__entry-description">{catalogEntry.description}</span>
|
|
<span className="reports__entry-period">
|
|
{catalogEntry.period_kind === 'point_in_time'
|
|
? 'Zustandsbericht'
|
|
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{selectedEntry !== null && (
|
|
<ReportPanel
|
|
catalogEntry={selectedEntry}
|
|
previewReport={previewReport}
|
|
exportState={exportState}
|
|
onExport={(outputFormat) => void generateReport(selectedEntry.type, outputFormat)}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Zeigt einen erzeugten Bericht mit seinen Ausgabemoeglichkeiten. */
|
|
function ReportPanel(properties: {
|
|
readonly catalogEntry: ReportCatalogEntry;
|
|
readonly previewReport: GeneratedReport | null;
|
|
readonly exportState: ExportState;
|
|
readonly onExport: (outputFormat: ReportFormat) => void;
|
|
}): React.JSX.Element {
|
|
const { catalogEntry, previewReport, exportState, onExport } = properties;
|
|
|
|
return (
|
|
<article className="reports__panel">
|
|
<header className="reports__panel-header">
|
|
<h2 className="reports__panel-title">{catalogEntry.title}</h2>
|
|
<div className="reports__actions">
|
|
<button
|
|
type="button"
|
|
className="button button--secondary"
|
|
disabled={exportState.runningFormat !== null}
|
|
onClick={() => onExport('csv')}
|
|
>
|
|
{exportState.runningFormat === 'csv' ? 'CSV wird erzeugt …' : 'CSV herunterladen'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="button button--secondary"
|
|
disabled={exportState.runningFormat !== null}
|
|
onClick={() => onExport('pdf')}
|
|
>
|
|
{exportState.runningFormat === 'pdf' ? 'PDF wird erzeugt …' : 'PDF herunterladen'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{exportState.exportError !== null && (
|
|
<p className="reports__error" role="alert">
|
|
{exportState.exportError.message}
|
|
<span className="reports__request-id">
|
|
Vorgang {exportState.exportError.requestId}
|
|
</span>
|
|
</p>
|
|
)}
|
|
|
|
{previewReport === null ? (
|
|
<LoadingState what="den Bericht" />
|
|
) : (
|
|
<ReportPreview report={previewReport} />
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
/** Stellt einen Bericht dar. */
|
|
function ReportPreview(properties: { readonly report: GeneratedReport }): React.JSX.Element {
|
|
const { report } = properties;
|
|
|
|
// Ein Zustandsbericht hat keinen Zeitraum. Zwei gleiche Zeitpunkte
|
|
// auszuweisen sähe nach einem Fehler aus.
|
|
const isPointInTime = report.period_from === report.period_to;
|
|
|
|
return (
|
|
<div className="reports__preview">
|
|
<p className="reports__meta">
|
|
{isPointInTime
|
|
? `Zustand zum ${formatTimestamp(report.period_to)}`
|
|
: `Zeitraum ${formatTimestamp(report.period_from)} bis ${formatTimestamp(report.period_to)}`}
|
|
{' · '}
|
|
Erzeugt am {formatTimestamp(report.generated_at)}
|
|
{report.generated_by !== undefined && report.generated_by !== '' && ` von ${report.generated_by}`}
|
|
</p>
|
|
|
|
{report.sections.map((reportSection) => (
|
|
<section key={reportSection.title} className="reports__section">
|
|
<h3 className="reports__section-title">{reportSection.title}</h3>
|
|
{reportSection.description !== undefined && (
|
|
<p className="reports__section-description">{reportSection.description}</p>
|
|
)}
|
|
|
|
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 && (
|
|
<dl className="reports__metrics">
|
|
{reportSection.metrics.map((reportMetric) => (
|
|
<div key={reportMetric.label} className="reports__metric">
|
|
<dt className="reports__metric-label">{reportMetric.label}</dt>
|
|
<dd
|
|
className={
|
|
reportMetric.is_known
|
|
? 'reports__metric-value'
|
|
: 'reports__metric-value reports__metric-value--unknown'
|
|
}
|
|
>
|
|
{formatMetricValue(reportMetric)}
|
|
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined && (
|
|
<span className="reports__metric-reason">{reportMetric.unknown_reason}</span>
|
|
)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
{reportSection.tables?.map((reportTable) => (
|
|
<ReportTableView key={reportTable.title} table={reportTable} />
|
|
))}
|
|
</section>
|
|
))}
|
|
|
|
{report.notes !== undefined && report.notes.length > 0 && (
|
|
<section className="reports__notes">
|
|
<h3 className="reports__section-title">Hinweise</h3>
|
|
<ul className="reports__note-list">
|
|
{report.notes.map((note) => (
|
|
<li key={note}>{note}</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Zahl der Zeilen, die in der Vorschau gezeigt werden. */
|
|
const previewRowLimit = 25;
|
|
|
|
/** Stellt eine Tabelle dar. */
|
|
function ReportTableView(properties: { readonly table: ReportTable }): React.JSX.Element {
|
|
const { table } = properties;
|
|
|
|
if (table.rows.length === 0) {
|
|
return (
|
|
<div className="reports__table">
|
|
<h4 className="reports__table-title">{table.title}</h4>
|
|
<p className="reports__empty">{table.empty_notice ?? 'Keine Einträge.'}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const visibleRows = table.rows.slice(0, previewRowLimit);
|
|
|
|
return (
|
|
<div className="reports__table">
|
|
<h4 className="reports__table-title">{table.title}</h4>
|
|
<div className="reports__table-scroll">
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
{table.columns.map((columnLabel) => (
|
|
<th key={columnLabel}>{columnLabel}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
{/*
|
|
Die Position ist der Schluessel: Eine Berichtszeile traegt keine
|
|
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein.
|
|
Unbedenklich, weil die Tabelle statisch ist — sie wird weder
|
|
umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut.
|
|
*/}
|
|
<tbody>
|
|
{visibleRows.map((tableRow, rowIndex) => (
|
|
<tr key={rowIndex}>
|
|
{tableRow.map((cellValue, cellIndex) => (
|
|
<td key={cellIndex}>{cellValue}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{table.rows.length > visibleRows.length && (
|
|
<p className="reports__truncation">
|
|
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
|
|
heruntergeladene Datei enthält alle.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stellt den Wert einer Kennzahl dar.
|
|
*
|
|
* Eine ungemessene Kennzahl zeigt „nicht gemessen", keine Null. Der Unterschied
|
|
* ist der Kern des ganzen Berichtswesens.
|
|
*/
|
|
function formatMetricValue(reportMetric: ReportMetric): string {
|
|
if (!reportMetric.is_known) {
|
|
return 'nicht gemessen';
|
|
}
|
|
|
|
if (reportMetric.text !== undefined && reportMetric.text !== '') {
|
|
return reportMetric.text;
|
|
}
|
|
|
|
const numericValue = reportMetric.value ?? 0;
|
|
|
|
switch (reportMetric.unit) {
|
|
case 'bytes':
|
|
return formatBytes(numericValue);
|
|
case 'seconds':
|
|
return formatDuration(numericValue);
|
|
case 'percent':
|
|
return formatPercent(numericValue);
|
|
case 'count':
|
|
return numericValue.toLocaleString('de-DE');
|
|
default:
|
|
return numericValue.toLocaleString('de-DE');
|
|
}
|
|
}
|
|
|
|
/** Stellt eine Datenmenge lesbar dar. */
|
|
function formatBytes(byteCount: number): string {
|
|
const unitNames = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
|
let scaledValue = byteCount;
|
|
let unitIndex = 0;
|
|
|
|
while (scaledValue >= 1024 && unitIndex < unitNames.length - 1) {
|
|
scaledValue /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
return `${scaledValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${unitNames[unitIndex]}`;
|
|
}
|
|
|
|
/** Stellt eine Dauer lesbar dar. */
|
|
function formatDuration(seconds: number): string {
|
|
if (seconds < 1) {
|
|
return `${Math.round(seconds * 1000)} ms`;
|
|
}
|
|
|
|
if (seconds < 60) {
|
|
return `${seconds.toLocaleString('de-DE', { maximumFractionDigits: 1 })} s`;
|
|
}
|
|
|
|
if (seconds < 3600) {
|
|
return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
|
|
}
|
|
|
|
if (seconds < 86400) {
|
|
return `${Math.floor(seconds / 3600)} h ${Math.round((seconds % 3600) / 60)} min`;
|
|
}
|
|
|
|
return `${Math.floor(seconds / 86400)} Tage ${Math.round((seconds % 86400) / 3600)} h`;
|
|
}
|
|
|
|
/**
|
|
* Stellt einen Anteil dar.
|
|
*
|
|
* Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent
|
|
* liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Uebersicht).
|
|
*/
|
|
function formatPercent(percentValue: number): string {
|
|
if (percentValue > 0 && percentValue < 0.1) {
|
|
return '< 0,1 %';
|
|
}
|
|
|
|
return `${percentValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`;
|
|
}
|
|
|
|
/** Stellt einen Zeitpunkt in Ortszeit dar. */
|
|
function formatTimestamp(isoTimestamp: string): string {
|
|
return new Date(isoTimestamp).toLocaleString('de-DE');
|
|
}
|
|
|
|
/**
|
|
* Loest den Browser-Download einer Datei aus.
|
|
*
|
|
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser
|
|
* jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei
|
|
* einem Monatsbericht ueber eine grosse Anlage sind das schnell einige Megabyte
|
|
* je Klick.
|
|
*/
|
|
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {
|
|
const objectUrl = URL.createObjectURL(fileBlob);
|
|
const downloadLink = document.createElement('a');
|
|
|
|
downloadLink.href = objectUrl;
|
|
downloadLink.download = fileName;
|
|
document.body.appendChild(downloadLink);
|
|
downloadLink.click();
|
|
document.body.removeChild(downloadLink);
|
|
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|