From 72dba1c03e87fd8277a3a31cb0a3466b36bede21 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:39:19 +0200 Subject: [PATCH] feat(web): add peer list with filtering, sorting, and compact rows --- .../FederationPanel.tsx | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index 52e62518..622d1b65 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; +import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { api } from '../../../api/client'; import type { InstanceAdminSettings } from '@backspace/shared'; import type { FederationPeer } from '../../../api/client'; @@ -137,9 +138,322 @@ function FederationGlobalSettings() { ); } +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatRelativeTime(timestamp: number | null): string { + if (!timestamp) return 'Never'; + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return 'Just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatAbsoluteDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function peerStatusColor(status: string): string { + switch (status) { + case 'active': return 'bg-status-online/15 text-status-online'; + case 'pending': return 'bg-accent-lavender/15 text-accent-lavender'; + case 'unreachable': return 'bg-accent-amber/15 text-accent-amber'; + case 'revoked': return 'bg-white/5 text-txt-tertiary'; + default: return 'bg-white/5 text-txt-tertiary'; + } +} + +function peerStatusDotColor(status: string): string { + switch (status) { + case 'active': return 'bg-status-online'; + case 'pending': return 'bg-accent-lavender'; + case 'unreachable': return 'bg-accent-amber'; + default: return 'bg-txt-tertiary'; + } +} + +type PeerView = 'active' | 'revoked'; +type SortBy = 'name' | 'lastSeen' | 'dateAdded' | 'failures'; +type StatusFilter = 'active' | 'unreachable' | 'pending'; + +// ─── Filter Dropdown ───────────────────────────────────────────────────────── + +function FilterDropdown({ + view, + statusFilter, + setStatusFilter, + sortBy, + setSortBy, +}: { + view: PeerView; + statusFilter: Set; + setStatusFilter: (f: Set) => void; + sortBy: SortBy; + setSortBy: (s: SortBy) => void; +}) { + const [open, setOpen] = useState(false); + + const toggleStatus = (s: StatusFilter) => { + const next = new Set(statusFilter); + if (next.has(s)) { + if (next.size > 1) next.delete(s); + } else { + next.add(s); + } + setStatusFilter(next); + }; + + const sortOptions: Array<{ key: SortBy; label: string }> = view === 'active' + ? [ + { key: 'name', label: 'Name (A-Z)' }, + { key: 'lastSeen', label: 'Last seen' }, + { key: 'dateAdded', label: 'Date added' }, + { key: 'failures', label: 'Failures' }, + ] + : [ + { key: 'name', label: 'Name (A-Z)' }, + { key: 'dateAdded', label: 'Revoked date' }, + ]; + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {view === 'active' && ( + <> +
Status
+ {(['active', 'unreachable', 'pending'] as StatusFilter[]).map((s) => ( + + ))} +
+ + )} +
Sort by
+ {sortOptions.map((opt) => ( + + ))} +
+ + )} +
+ ); +} + +// ─── Peer List Controls ────────────────────────────────────────────────────── + +function PeerListControls({ + view, + setView, + activeCount, + revokedCount, + statusFilter, + setStatusFilter, + sortBy, + setSortBy, +}: { + view: PeerView; + setView: (v: PeerView) => void; + activeCount: number; + revokedCount: number; + statusFilter: Set; + setStatusFilter: (f: Set) => void; + sortBy: SortBy; + setSortBy: (s: SortBy) => void; +}) { + return ( +
+
+ + +
+ +
+ ); +} + +// ─── Sorting ───────────────────────────────────────────────────────────────── + +function sortPeers(peers: FederationPeer[], sortBy: SortBy, view: PeerView): FederationPeer[] { + return [...peers].sort((a, b) => { + switch (sortBy) { + case 'name': { + const nameA = (a.instanceName || new URL(a.origin).host).toLowerCase(); + const nameB = (b.instanceName || new URL(b.origin).host).toLowerCase(); + return nameA.localeCompare(nameB); + } + case 'lastSeen': + return (b.lastSeenAt ?? 0) - (a.lastSeenAt ?? 0); + case 'dateAdded': + return b.createdAt - a.createdAt; + case 'failures': + return (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0); + default: + return 0; + } + }); +} + +// ─── Peer Row ──────────────────────────────────────────────────────────────── + +function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRotateIntervalDays }: { + peer: FederationPeer; + view: PeerView; + expanded: boolean; + onToggleExpand: () => void; + onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete') => void; + defaultAutoRotateIntervalDays: number; +}) { + const name = peer.instanceName || new URL(peer.origin).host; + const isRevoked = view === 'revoked'; + + return ( +
+
+
+
+
+
+ {name} +
+
+ {isRevoked + ? `Revoked: ${formatAbsoluteDate(peer.lastSeenAt ?? peer.createdAt)} · Peered: ${formatAbsoluteDate(peer.createdAt)}` + : peer.status === 'unreachable' + ? `Last seen: ${formatRelativeTime(peer.lastSeenAt)} · ${peer.consecutiveFailures ?? 0} failures` + : `Last seen: ${formatRelativeTime(peer.lastSeenAt)} · Synced: ${formatRelativeTime(peer.lastSyncedAt)}` + } +
+
+
+
+ + {peer.status} + + {expanded ? '▾' : '▸'} +
+
+
+ ); +} + // ─── Main Panel ────────────────────────────────────────────────────────────── export function FederationPanel() { + const addToast = useUIStore((s) => s.addToast); + + // Peer list state + const [peers, setPeers] = useState([]); + const [peersLoading, setPeersLoading] = useState(false); + const [peersError, setPeersError] = useState(''); + const [view, setView] = useState('active'); + const [statusFilter, setStatusFilter] = useState>(new Set(['active', 'unreachable', 'pending'])); + const [sortBy, setSortBy] = useState('name'); + const [expandedPeerId, setExpandedPeerId] = useState(null); + + // Confirm dialog state (used in Task 10) + const [confirmAction, setConfirmAction] = useState<{ + type: 'rotate' | 'revoke' | 'reinitiate' | 'delete'; + peer: FederationPeer; + } | null>(null); + const [actionLoading, setActionLoading] = useState(false); + + const fetchPeers = useCallback(async () => { + setPeersLoading(true); + setPeersError(''); + try { + const result = await api.federation.peers(); + setPeers(result.peers); + } catch (err) { + setPeersError(err instanceof Error ? err.message : 'Failed to load peers'); + } finally { + setPeersLoading(false); + } + }, []); + + useEffect(() => { + fetchPeers(); + }, [fetchPeers]); + + // Derived peer lists + const activePeers = peers.filter((p) => p.status !== 'revoked'); + const revokedPeers = peers.filter((p) => p.status === 'revoked'); + + const filteredPeers = view === 'active' + ? activePeers.filter((p) => statusFilter.has(p.status as StatusFilter)) + : revokedPeers; + + const sortedPeers = sortPeers(filteredPeers, sortBy, view); + + // Empty state message + const emptyMessage = peers.length === 0 + ? 'No federation peers configured. Peers are created automatically when users connect to remote instances.' + : view === 'active' && filteredPeers.length === 0 + ? 'No peers match the current filter.' + : view === 'revoked' && revokedPeers.length === 0 + ? 'No revoked peers.' + : null; + return (
e.preventDefault()}>

Federation

@@ -148,6 +462,63 @@ export function FederationPanel() {
+ + {/* Peered Instances */} +
+
+
Peered Instances
+ +
+ +
+ {peers.length > 0 && ( + + )} + + {peersError && ( +
+ {peersError} + +
+ )} + + {sortedPeers.length > 0 && ( +
+ {sortedPeers.map((peer) => ( + setExpandedPeerId(expandedPeerId === peer.id ? null : peer.id)} + onAction={(type) => setConfirmAction({ type, peer })} + defaultAutoRotateIntervalDays={useSettingsStore.getState().instanceSettings?.defaultAutoRotateIntervalDays ?? 90} + /> + ))} +
+ )} + + {emptyMessage && !peersError && !peersLoading && ( +
{emptyMessage}
+ )} +
+
); }