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>
155 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|