syncova-backup/apps/web/src/features/dashboard/DashboardPage.tsx
Jerrit Fritzsche 6e23696fcb
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: letzte Seiten auf das Design-System gezogen
Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:50:15 +02:00

158 lines
5.4 KiB
TypeScript

/**
* Uebersicht der Anlage.
*
* Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
* Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht
* aus wie ein vergessenes; ein mit einer Null gefuelltes waere eine erfundene
* Statistik (PROMPT.md §139).
*
* Der Schweregrad faerbt nur die Randlinie, nicht die Kachel. Zehn farbige
* Flaechen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
* untergeht — und genau die soll auffallen.
*/
import { useCallback } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card, ErrorState, LoadingState } from '@/components/ui';
import { cn } from '@/lib/utils';
import { fetchDashboard } from './dashboardApi';
import type { Dashboard, DashboardWidget } from './dashboardApi';
export function DashboardPage(): React.JSX.Element {
const loadDashboard = useCallback(
(abortSignal: AbortSignal) => fetchDashboard(abortSignal),
[],
);
const { loadState, data, loadError, reload } = useApiResource<Dashboard>(loadDashboard);
return (
<>
<PageHeader
title="Uebersicht"
description={
data !== null
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
: undefined
}
/>
{loadState === 'loading' ? (
<Card>
<LoadingState label="Die Kennzahlen werden geladen …" />
</Card>
) : null}
{loadState === 'failed' && loadError !== null ? (
<Card>
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
</Card>
) : null}
{loadState === 'loaded' && data !== null ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data.widgets.map((dashboardWidget) => (
<WidgetCard key={dashboardWidget.key} widget={dashboardWidget} />
))}
</div>
) : null}
</>
);
}
/** Randfarbe je Schweregrad. Nur die Linie, nicht die Flaeche. */
const severityBorderClasses: Readonly<Record<string, string>> = {
healthy: 'border-l-healthy',
info: 'border-l-info',
warning: 'border-l-warning',
high: 'border-l-high',
critical: 'border-l-critical',
};
/** Zeigt eine einzelne Kennzahl. */
function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX.Element {
if (!widget.available) {
return (
<Card className="border-l-4 border-l-neutral p-4">
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfuegbar</p>
<p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
</Card>
);
}
return (
<Card
className={cn(
'border-l-4 p-4',
severityBorderClasses[widget.severity] ?? 'border-l-neutral',
)}
>
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="mt-2 flex items-baseline gap-1.5">
{widget.value === undefined ? (
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
// laesst, sagt das — der Grund steht im Detailtext darunter.
<span className="text-sm text-fg-muted">nicht bezifferbar</span>
) : (
<>
<span className="text-2xl font-semibold tabular text-fg">
{formatWidgetValue(widget.value)}
</span>
{widget.unit !== undefined ? (
<span className="text-sm text-fg-muted">{widget.unit}</span>
) : null}
</>
)}
</p>
{widget.detail !== undefined ? (
<p className="mt-1 text-xs text-fg-muted">{widget.detail}</p>
) : null}
{widget.breakdown !== undefined ? (
<dl className="mt-3 space-y-1 border-t border-line pt-2">
{Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => (
<div className="flex justify-between gap-2 text-xs" key={breakdownKey}>
<dt className="truncate text-fg-muted">{formatBreakdownLabel(breakdownKey)}</dt>
<dd className="tabular text-fg">{breakdownValue.toLocaleString('de-DE')}</dd>
</div>
))}
</dl>
) : null}
</Card>
);
}
/** Formatiert die Hauptzahl einer Kennzahl. */
function formatWidgetValue(widgetValue: number): string {
// Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest
// sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Ueberlegung wie
// bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie
// weiss, und auch nicht weniger.
if (widgetValue > 0 && widgetValue < 0.1) {
return '< 0,1';
}
// Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt
// mehr als „100 %", wenn drei von tausend Laeufen scheiterten.
if (Number.isInteger(widgetValue)) {
return widgetValue.toLocaleString('de-DE');
}
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
}
/** Macht aus einem Schluessel der Aufschluesselung eine Beschriftung. */
function formatBreakdownLabel(breakdownKey: string): string {
const readableLabel = breakdownKey.replace(/_/g, ' ');
return readableLabel.charAt(0).toUpperCase() + readableLabel.slice(1);
}