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:
1 parent
f9bfca9211
commit
f5bd89cd6f
6 files changed
+359
-7
No files matched your search
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user