syncova-backup/apps/web/src/features/metrics/MetricsPage.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

155 lines
5.1 KiB
TypeScript

/**
* Kennzahlen und Diagramme.
*
* Alle zwoelf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage
* — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt.
* Dieselbe Regel wie in der Uebersicht: Ein weggelassenes Diagramm sieht aus wie
* ein vergessenes, ein leeres wie eine Anlage ohne Betrieb.
*/
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card, CardContent, CardHeader, ErrorState, LoadingState, SelectInput } from '@/components/ui';
import { LineChart } from './LineChart';
import { fetchChart, fetchChartCatalog } from './metricsApi';
import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi';
/** Auswaehlbare Zeitraeume mit ihrer Beschriftung. */
const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '1h', label: 'Letzte Stunde' },
{ value: '24h', label: 'Letzte 24 Stunden' },
{ value: '7d', label: 'Letzte 7 Tage' },
{ value: '30d', label: 'Letzte 30 Tage' },
{ value: '90d', label: 'Letzte 90 Tage' },
{ value: '1y', label: 'Letztes Jahr' },
];
/** Zeigt die Kennzahlen. */
export function MetricsPage(): React.JSX.Element {
const [selectedRange, setSelectedRange] = useState('7d');
const loadCatalog = useCallback((abortSignal: AbortSignal) => fetchChartCatalog(abortSignal), []);
const { loadState, data, loadError, reload } = useApiResource<ChartCatalog>(loadCatalog);
return (
<>
<PageHeader
title="Kennzahlen"
description={
data !== null
? `${data.available_count} von ${data.charts.length} Diagrammen haben eine Datengrundlage.`
: undefined
}
actions={
<SelectInput
aria-label="Zeitraum"
className="w-52"
value={selectedRange}
onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)}
>
{RANGE_OPTIONS.map((rangeOption) => (
<option key={rangeOption.value} value={rangeOption.value}>
{rangeOption.label}
</option>
))}
</SelectInput>
}
/>
{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-4 xl:grid-cols-2">
{data.charts.map((chartDefinition) => (
<ChartCard
key={chartDefinition.metric}
definition={chartDefinition}
timeRange={selectedRange}
/>
))}
</div>
) : null}
</>
);
}
/** Eigenschaften einer Diagrammkachel. */
interface ChartCardProperties {
/** Das darzustellende Diagramm. */
readonly definition: ChartDefinition;
/** Der gewaehlte Zeitraum. */
readonly timeRange: string;
}
/** Zeigt ein einzelnes Diagramm. */
function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.Element {
const loadChart = useCallback(
(abortSignal: AbortSignal) => fetchChart(definition.metric, timeRange, abortSignal),
[definition.metric, timeRange],
);
// Ein Diagramm ohne Datengrundlage wird gar nicht erst abgerufen: Der Server
// antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht.
const { loadState, data, loadError, reload } = useApiResource<Chart>(
loadChart,
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
);
if (!definition.available) {
return (
<Card>
<CardHeader title={definition.title} description="noch nicht verfuegbar" />
<CardContent>
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader title={definition.title} description={definition.description} />
<CardContent>
{loadState === 'loading' ? <LoadingState label="Die Reihe wird geladen …" /> : null}
{loadState === 'failed' && loadError !== null ? (
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
) : null}
{loadState === 'loaded' && data !== null ? (
<>
<LineChart series={data.series} unit={definition.unit} />
{/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen
aus wie ein Trend und sind keiner. */}
{data.note !== undefined && data.note !== '' ? (
<p className="mt-2 text-xs text-fg-muted">{data.note}</p>
) : null}
</>
) : null}
</CardContent>
</Card>
);
}