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 && (
+
+
+
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."}
+