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>
158 lines
5.4 KiB
TypeScript
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);
|
|
}
|