From f5bd89cd6f4b0880f51f57e40d9544e21f2b9692 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 15 Sep 2026 00:04:24 +0200 Subject: [PATCH] feat(PROJ-16): frontend for federation status (SYSTEM_ADMIN) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- features/PROJ-16-federation-status.md | 20 +- frontend/src/App.tsx | 3 + .../src/hooks/use-federation-destinations.ts | 125 +++++++++++ .../src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++ frontend/src/pages/SystemAdminMediaPage.tsx | 6 +- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +- 6 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/use-federation-destinations.ts create mode 100644 frontend/src/pages/SystemAdminFederationPage.tsx diff --git a/features/PROJ-16-federation-status.md b/features/PROJ-16-federation-status.md index ea741b2..ffdd2bc 100644 --- a/features/PROJ-16-federation-status.md +++ b/features/PROJ-16-federation-status.md @@ -15,10 +15,10 @@ Anzeige, welche externen Matrix-Server aktuell föderiert sind — nachgebaut au 2. Als SYSTEM_ADMIN will ich bei aktivierter Federation einen fehlgeschlagenen Verbindungsversuch zu einem Server erkennen können. ## Acceptance Criteria -- [ ] `GET /api/system-admin/federation` — SYSTEM_ADMIN-only, ruft Synapse Admin API für Federation-Status ab (`GET /_synapse/admin/v1/federation/destinations`) -- [ ] Tabelle: Server-Name, letzter erfolgreicher Kontakt, Retry-Status (falls fehlgeschlagen) -- [ ] Leerer Zustand explizit als "Federation ist deaktiviert — keine externen Server verbunden" dargestellt, nicht als generisches "Keine Daten", damit klar ist dass das der Erwartungswert ist -- [ ] Kein Schreibzugriff (kein "Server blockieren"-Button im MVP) — nur Anzeige +- [x] `GET /api/system-admin/federation` — SYSTEM_ADMIN-only, ruft Synapse Admin API für Federation-Status ab (`GET /_synapse/admin/v1/federation/destinations`) +- [x] Tabelle: Server-Name, letzter erfolgreicher Kontakt, Retry-Status (falls fehlgeschlagen) +- [x] Leerer Zustand explizit als "Federation ist deaktiviert — keine externen Server verbunden" dargestellt, nicht als generisches "Keine Daten", damit klar ist dass das der Erwartungswert ist +- [x] Kein Schreibzugriff (kein "Server blockieren"-Button im MVP) — nur Anzeige ## Edge Cases - Federation deaktiviert (aktueller Normalfall) → Synapse Admin API liefert vermutlich leere Liste oder einen Fehler, je nach Synapse-Version — beides sauber als "deaktiviert" behandeln, nicht als Fehlerzustand der Seite @@ -65,4 +65,14 @@ Vorabprüfung gegen die echte Synapse-Instanz abgeschlossen — keine offenen te - Antwort unterscheidet bewusst drei Fälle: `federationAvailable: false` (Admin-API nicht vorhanden → "deaktiviert"), leere Liste bei `federationAvailable: true` (Normalfall, kein Fehler) und HTTP 502 (Synapse nicht erreichbar — wird **nicht** zu einer leeren Liste verflacht). - Zeitstempel `0` werden zu `null` normalisiert; `nextRetryAt` wird aus `retry_last_ts + retry_interval` berechnet. - Kein DB-Schema, keine Migration, kein Audit-Eintrag (reiner Lesezugriff, wie die Raum-Liste in PROJ-14). -- Offen: Frontend-Seite `/system-admin/federation` (Tabelle + Hinweistext für den leeren Zustand). +- ~~Offen: Frontend-Seite `/system-admin/federation`~~ — erledigt, siehe Implementation Notes (Frontend). + +## Implementation Notes (Frontend) +- `frontend/src/hooks/use-federation-destinations.ts` — Fetch-Hook mit Bearer-Token, `AbortController`-Cleanup und `refresh()`. Hält die drei Backend-Fälle bewusst auseinander: Fehler (502), `federationAvailable: false` und leere Liste bei verfügbarer API. Bei einem Fehler wird die Liste geleert und `federationAvailable` **nicht** auf `false` gesetzt — ein nicht erreichbares Synapse darf nie als „Föderation ist deaktiviert“ (also als bestätigte Isolation) erscheinen. +- `frontend/src/pages/SystemAdminFederationPage.tsx` — Tabelle (Server, Status-Badge, letzter Kontaktversuch, Wiederholungs-Details) nur wenn Einträge vorhanden sind; sonst eine erklärende Card statt einer leeren Tabelle. Der Hinweistext variiert je nach `federationAvailable`, sagt in beiden Fällen aber explizit, dass das der erwartete Zustand ist. +- Zustände: Skeleton-Zeilen beim Laden, destruktiver Alert mit „Erneut versuchen“ bei Fehler, erklärender Leerzustand mit „Aktualisieren“. +- `retryIntervalMs` wird für die Anzeige in Minuten/Stunden/Tage umgerechnet; Zeitstempel via `toLocaleString("de-DE")`. +- Route `/system-admin/federation` in `App.tsx` innerhalb von `SystemAdminRoute` (Nav-Guard, Autorisierung bleibt serverseitig). Quer-Links zwischen Räume-, Medien- und Föderations-Seite ergänzt (`flex-wrap` für Mobile). +- Kein Schreibzugriff in der UI (Non-Goal); ein Hinweis erklärt, dass Föderation nur über `homeserver.yaml` steuerbar ist, damit niemand einen fehlenden Button für einen Bug hält. +- Paginierung: `nextToken` vom Backend für „Weiter“, `from - limit` für „Zurück“ (Synapse liefert keinen Rückwärts-Cursor). Wird nur eingeblendet, wenn es überhaupt Einträge gibt. +- Keine neuen Pakete, nur vorhandene shadcn/ui-Komponenten (table, alert, badge, button, card, skeleton). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 621bed9..750de01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { AuditLogPage } from "@/pages/AuditLogPage"; import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage"; import { SystemAdminRoomsPage } from "@/pages/SystemAdminRoomsPage"; import { SystemAdminMediaPage } from "@/pages/SystemAdminMediaPage"; +import { SystemAdminFederationPage } from "@/pages/SystemAdminFederationPage"; function App() { return ( @@ -43,6 +44,8 @@ function App() { } /> {/* PROJ-15 */} } /> + {/* PROJ-16 */} + } /> } /> diff --git a/frontend/src/hooks/use-federation-destinations.ts b/frontend/src/hooks/use-federation-destinations.ts new file mode 100644 index 0000000..8c505fa --- /dev/null +++ b/frontend/src/hooks/use-federation-destinations.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useState } from "react"; + +export interface FederationDestination { + destination: string; + failing: boolean; + failureSince: string | null; + lastRetryAt: string | null; + retryIntervalMs: number | null; + nextRetryAt: string | null; + lastSuccessfulStreamOrdering: number | null; +} + +interface FederationPagination { + from: number; + limit: number; + total: number; + nextToken: number | null; +} + +interface UseFederationDestinationsResult { + destinations: FederationDestination[]; + pagination: FederationPagination | null; + /** + * `false` means Synapse has no federation admin API at all — the build is + * running without federation. Distinct from "available but no servers". + */ + federationAvailable: boolean; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +export const FEDERATION_PAGE_SIZE = 50; + +async function parseError(res: Response): Promise { + try { + const body = (await res.json()) as { error?: string }; + return body.error ?? `Anfrage fehlgeschlagen (${res.status})`; + } catch { + return `Anfrage fehlgeschlagen (${res.status})`; + } +} + +/** + * PROJ-16: read-only federation destination list. + * + * The three backend outcomes are kept apart on purpose, because this page's + * whole point is verifying isolation: + * - error (502) → something is wrong, say so loudly + * - federationAvailable: false → federation is off, expected state + * - available + empty destinations → federation on, nothing connected yet + * + * Collapsing an outage into "no servers connected" would let an admin read a + * broken Synapse as a clean bill of health, so on failure the list is cleared + * and `error` is set rather than rendering an empty, reassuring table. + */ +export function useFederationDestinations( + token: string | null, + from: number, +): UseFederationDestinationsResult { + const [destinations, setDestinations] = useState([]); + const [pagination, setPagination] = useState(null); + const [federationAvailable, setFederationAvailable] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback( + async (signal?: AbortSignal) => { + if (!token) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + from: String(from), + limit: String(FEDERATION_PAGE_SIZE), + }); + const res = await fetch(`/api/system-admin/federation?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + if (!res.ok) { + throw new Error(await parseError(res)); + } + const body = (await res.json()) as { + federationAvailable: boolean; + destinations: FederationDestination[]; + pagination: FederationPagination; + }; + setFederationAvailable(body.federationAvailable); + setDestinations(body.destinations); + setPagination(body.pagination); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + setDestinations([]); + setPagination(null); + // Unknown, not "off": an unreachable Synapse must not be rendered as + // the reassuring "Federation ist deaktiviert" message. + setFederationAvailable(true); + } + } finally { + setLoading(false); + } + }, + [token, from], + ); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + return { + destinations, + pagination, + federationAvailable, + loading, + error, + refresh: () => load(), + }; +} diff --git a/frontend/src/pages/SystemAdminFederationPage.tsx b/frontend/src/pages/SystemAdminFederationPage.tsx new file mode 100644 index 0000000..43ca685 --- /dev/null +++ b/frontend/src/pages/SystemAdminFederationPage.tsx @@ -0,0 +1,206 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/lib/auth-context"; +import { + useFederationDestinations, + FEDERATION_PAGE_SIZE, + type FederationDestination, +} from "@/hooks/use-federation-destinations"; + +function formatDateTime(value: string | null): string { + return value ? new Date(value).toLocaleString("de-DE") : "—"; +} + +/** + * Synapse reports the backoff window in milliseconds; minutes/hours read far + * better in a status table than "3600000". + */ +function formatInterval(ms: number | null): string | null { + if (!ms) return null; + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "unter 1 Minute"; + if (minutes < 60) return `${minutes} Minuten`; + const hours = Math.round(minutes / 60); + return hours < 24 ? `${hours} Stunden` : `${Math.round(hours / 24)} Tage`; +} + +export function SystemAdminFederationPage() { + const { token } = useAuth(); + const [from, setFrom] = useState(0); + const { destinations, pagination, federationAvailable, loading, error, refresh } = + useFederationDestinations(token, from); + + const hasNextPage = pagination?.nextToken != null; + const hasPrevPage = from > 0; + // The table is only worth rendering when there is something in it — the empty + // case gets its own explanatory panel (see below). + const showTable = loading || destinations.length > 0; + + return ( +
+
+
+

Föderation

+

+ Externe Matrix-Server, mit denen die Synapse-Instanz zuletzt Kontakt hatte. +

+
+
+ + + +
+
+ + {/* Read-only by design (PROJ-16 non-goal: no block/unblock, no config UI). + Stated up front so nobody hunts for an action button that is absent + on purpose. */} + + + Diese Seite dient ausschließlich der Kontrolle. Föderation wird in der + Server-Konfiguration (homeserver.yaml) aktiviert oder + deaktiviert — hier lässt sich nichts ändern und kein Server blockieren. + + + + {error && ( + + + {/* Deliberately NOT shown as "keine Server verbunden": an outage must + not look like verified isolation. */} + {error} + + + + )} + + {showTable && ( +
+ + + + + Server + Status + Letzter Kontaktversuch + Wiederholung + + + + {loading + ? Array.from({ length: 3 }).map((_, index) => ( + + + + + + )) + : destinations.map((destination: FederationDestination) => ( + + + {destination.destination} + + + {destination.failing ? ( + Fehlgeschlagen + ) : ( + Erreichbar + )} + + + {formatDateTime(destination.lastRetryAt)} + + + {destination.failing ? ( + <> + + Nächster Versuch: {formatDateTime(destination.nextRetryAt)} + + {formatInterval(destination.retryIntervalMs) && ( + + Abstand: {formatInterval(destination.retryIntervalMs)} + + )} + {destination.failureSince && ( + + Fehlerhaft seit: {formatDateTime(destination.failureSince)} + + )} + + ) : ( + "—" + )} + + + ))} + +
Föderierte Matrix-Server und ihr Verbindungsstatus
+
+ )} + + {/* An empty list is the *expected, desired* outcome here, not a defect. + A generic "Keine Daten" would read as a broken page, so the state is + spelled out as a confirmation instead (PROJ-16 acceptance criterion). */} + {!loading && !error && destinations.length === 0 && ( + + +

Föderation ist deaktiviert — keine externen Server verbunden.

+

+ {federationAvailable + ? "Die Synapse-Instanz hatte mit keinem anderen Matrix-Server Kontakt. Das ist der erwartete Zustand für TalkTime: Nachrichten verlassen diesen Server nicht." + : "Die Synapse-Instanz stellt keine Föderations-Schnittstelle bereit, Föderation ist also serverseitig vollständig abgeschaltet. Das ist der erwartete Zustand für TalkTime."} +

+ +
+
+ )} + + {(destinations.length > 0 || hasPrevPage) && ( +
+

+ {pagination?.total ?? destinations.length} Server gesamt +

+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/SystemAdminMediaPage.tsx b/frontend/src/pages/SystemAdminMediaPage.tsx index 8e8da41..eede343 100644 --- a/frontend/src/pages/SystemAdminMediaPage.tsx +++ b/frontend/src/pages/SystemAdminMediaPage.tsx @@ -94,10 +94,14 @@ export function SystemAdminMediaPage() { Wartung des Synapse-Medien-Zwischenspeichers.

-
+
+ {/* PROJ-16 */} + diff --git a/frontend/src/pages/SystemAdminRoomsPage.tsx b/frontend/src/pages/SystemAdminRoomsPage.tsx index 8280f39..c26723f 100644 --- a/frontend/src/pages/SystemAdminRoomsPage.tsx +++ b/frontend/src/pages/SystemAdminRoomsPage.tsx @@ -109,11 +109,15 @@ export function SystemAdminRoomsPage() { Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.

-
+
{/* PROJ-15: second system-admin subpage. */} + {/* PROJ-16 */} +