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.
|
2. Als SYSTEM_ADMIN will ich bei aktivierter Federation einen fehlgeschlagenen Verbindungsversuch zu einem Server erkennen können.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## 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`)
|
- [x] `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)
|
- [x] 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
|
- [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
|
||||||
- [ ] Kein Schreibzugriff (kein "Server blockieren"-Button im MVP) — nur Anzeige
|
- [x] Kein Schreibzugriff (kein "Server blockieren"-Button im MVP) — nur Anzeige
|
||||||
|
|
||||||
## Edge Cases
|
## 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
|
- 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).
|
- 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.
|
- 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).
|
- 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 { AcceptInvitationPage } from "@/pages/AcceptInvitationPage";
|
||||||
import { SystemAdminRoomsPage } from "@/pages/SystemAdminRoomsPage";
|
import { SystemAdminRoomsPage } from "@/pages/SystemAdminRoomsPage";
|
||||||
import { SystemAdminMediaPage } from "@/pages/SystemAdminMediaPage";
|
import { SystemAdminMediaPage } from "@/pages/SystemAdminMediaPage";
|
||||||
|
import { SystemAdminFederationPage } from "@/pages/SystemAdminFederationPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +44,8 @@ function App() {
|
|||||||
<Route path="/system-admin/rooms" element={<SystemAdminRoomsPage />} />
|
<Route path="/system-admin/rooms" element={<SystemAdminRoomsPage />} />
|
||||||
{/* PROJ-15 */}
|
{/* PROJ-15 */}
|
||||||
<Route path="/system-admin/media" element={<SystemAdminMediaPage />} />
|
<Route path="/system-admin/media" element={<SystemAdminMediaPage />} />
|
||||||
|
{/* PROJ-16 */}
|
||||||
|
<Route path="/system-admin/federation" element={<SystemAdminFederationPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<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.
|
Wartung des Synapse-Medien-Zwischenspeichers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to="/system-admin/rooms">Matrix-Räume</Link>
|
<Link to="/system-admin/rooms">Matrix-Räume</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* PROJ-16 */}
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/system-admin/federation">Föderation</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to="/">Zurück zum Dashboard</Link>
|
<Link to="/">Zurück zum Dashboard</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -109,11 +109,15 @@ export function SystemAdminRoomsPage() {
|
|||||||
Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.
|
Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{/* PROJ-15: second system-admin subpage. */}
|
{/* PROJ-15: second system-admin subpage. */}
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to="/system-admin/media">Medien</Link>
|
<Link to="/system-admin/media">Medien</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* PROJ-16 */}
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/system-admin/federation">Föderation</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to="/">Zurück zum Dashboard</Link>
|
<Link to="/">Zurück zum Dashboard</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in new issue
Block a user