syncova-backup/apps/web/src/features/reports/ReportsPage.tsx
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

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);
}