feat(PROJ-16): frontend for federation status (SYSTEM_ADMIN)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-15 00:04:24 +02:00
1 parent f9bfca9211
commit f5bd89cd6f
6 files changed
+359 -7

No files matched your search

+15 -5
View File
@@ -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).
+3
View File
@@ -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() {
<Route path="/system-admin/rooms" element={<SystemAdminRoomsPage />} />
{/* PROJ-15 */}
<Route path="/system-admin/media" element={<SystemAdminMediaPage />} />
{/* PROJ-16 */}
<Route path="/system-admin/federation" element={<SystemAdminFederationPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
@@ -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<void>;
}
export const FEDERATION_PAGE_SIZE = 50;
async function parseError(res: Response): Promise<string> {
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<FederationDestination[]>([]);
const [pagination, setPagination] = useState<FederationPagination | null>(null);
const [federationAvailable, setFederationAvailable] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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(),
};
}
@@ -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 (
<main className="min-h-screen p-4 sm:p-6 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Föderation</h1>
<p className="text-sm text-muted-foreground">
Externe Matrix-Server, mit denen die Synapse-Instanz zuletzt Kontakt hatte.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link to="/system-admin/rooms">Matrix-Räume</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/system-admin/media">Medien</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/">Zurück zum Dashboard</Link>
</Button>
</div>
</div>
{/* 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. */}
<Alert>
<AlertDescription>
Diese Seite dient ausschließlich der Kontrolle. Föderation wird in der
Server-Konfiguration (<code className="text-xs">homeserver.yaml</code>) aktiviert oder
deaktiviert — hier lässt sich nichts ändern und kein Server blockieren.
</AlertDescription>
</Alert>
{error && (
<Alert variant="destructive">
<AlertDescription className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{/* Deliberately NOT shown as "keine Server verbunden": an outage must
not look like verified isolation. */}
<span>{error}</span>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
{showTable && (
<div className="overflow-x-auto">
<Table>
<caption className="sr-only">Föderierte Matrix-Server und ihr Verbindungsstatus</caption>
<TableHeader>
<TableRow>
<TableHead>Server</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-48">Letzter Kontaktversuch</TableHead>
<TableHead className="w-56">Wiederholung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading
? Array.from({ length: 3 }).map((_, index) => (
<TableRow key={index}>
<TableCell colSpan={4}>
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
))
: destinations.map((destination: FederationDestination) => (
<TableRow key={destination.destination}>
<TableCell className="font-medium break-all">
{destination.destination}
</TableCell>
<TableCell>
{destination.failing ? (
<Badge variant="destructive">Fehlgeschlagen</Badge>
) : (
<Badge variant="secondary">Erreichbar</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDateTime(destination.lastRetryAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{destination.failing ? (
<>
<span className="block">
Nächster Versuch: {formatDateTime(destination.nextRetryAt)}
</span>
{formatInterval(destination.retryIntervalMs) && (
<span className="block text-xs">
Abstand: {formatInterval(destination.retryIntervalMs)}
</span>
)}
{destination.failureSince && (
<span className="block text-xs">
Fehlerhaft seit: {formatDateTime(destination.failureSince)}
</span>
)}
</>
) : (
"—"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 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 && (
<Card>
<CardContent className="py-10 text-center space-y-2">
<p className="font-medium">Föderation ist deaktiviert — keine externen Server verbunden.</p>
<p className="text-sm text-muted-foreground mx-auto max-w-prose">
{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."}
</p>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
Aktualisieren
</Button>
</CardContent>
</Card>
)}
{(destinations.length > 0 || hasPrevPage) && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{pagination?.total ?? destinations.length} Server gesamt
</p>
<div className="flex gap-2">
<Button
variant="outline"
disabled={!hasPrevPage || loading}
onClick={() => setFrom(Math.max(0, from - FEDERATION_PAGE_SIZE))}
>
Zurück
</Button>
<Button
variant="outline"
disabled={!hasNextPage || loading}
onClick={() => setFrom(pagination?.nextToken ?? from)}
>
Weiter
</Button>
</div>
</div>
)}
</main>
);
}
+5 -1
View File
@@ -94,10 +94,14 @@ export function SystemAdminMediaPage() {
Wartung des Synapse-Medien-Zwischenspeichers.
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link to="/system-admin/rooms">Matrix-Räume</Link>
</Button>
{/* PROJ-16 */}
<Button variant="outline" asChild>
<Link to="/system-admin/federation">Föderation</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/">Zurück zum Dashboard</Link>
</Button>
+5 -1
View File
@@ -109,11 +109,15 @@ export function SystemAdminRoomsPage() {
Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{/* PROJ-15: second system-admin subpage. */}
<Button variant="outline" asChild>
<Link to="/system-admin/media">Medien</Link>
</Button>
{/* PROJ-16 */}
<Button variant="outline" asChild>
<Link to="/system-admin/federation">Föderation</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/">Zurück zum Dashboard</Link>
</Button>