feat(web): Check now button to manually recover unreachable federation peers

This commit is contained in:
Jannis Braun
2026-06-26 13:58:38 +02:00
parent 4ad83a7cc1
commit e372a7a571
3 changed files with 87 additions and 1 deletions
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { recheckPeer, addToast } = vi.hoisted(() => ({
recheckPeer: vi.fn(),
addToast: vi.fn(),
}));
vi.mock('../../../api/client', () => ({
api: {
federation: {
peers: vi.fn().mockResolvedValue({ peers: [{
id: 'p1', origin: 'https://peer.example', instanceName: 'Peer',
status: 'unreachable', consecutiveFailures: 10, lastSeenAt: Date.now(),
autoRotateIntervalDays: 90, rotationInProgress: false,
}] }),
approvalRequests: vi.fn().mockResolvedValue({ requests: [] }),
recheckPeer,
},
},
}));
vi.mock('../../../stores/uiStore', () => ({
useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }),
}));
vi.mock('../../../hooks/useWebSocket', () => ({
onFederationPeersChanged: () => () => {},
}));
import { FederationPanel } from './FederationPanel';
describe('FederationPanel — Check now', () => {
beforeEach(() => { recheckPeer.mockReset(); addToast.mockReset(); });
it('shows Check now for an unreachable peer and recovers it on click', async () => {
recheckPeer.mockResolvedValue({ recovered: true, status: 'active' });
render(<FederationPanel />);
// PeerRow collapses its action buttons; expand the card by clicking its header first.
const header = await screen.findByText('Peer');
fireEvent.click(header);
const btn = await screen.findByText('Check now');
fireEvent.click(btn);
await waitFor(() => expect(recheckPeer).toHaveBeenCalledWith('p1'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith(
expect.stringContaining('back online'), 'success', 3000));
});
});
@@ -388,12 +388,14 @@ function sortPeers(peers: FederationPeer[], sortBy: SortBy, view: PeerView): Fed
// ─── Peer Row ────────────────────────────────────────────────────────────────
function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRotateIntervalDays }: {
function PeerRow({ peer, view, expanded, onToggleExpand, onAction, onRecheck, recheckLoading, defaultAutoRotateIntervalDays }: {
peer: FederationPeer;
view: PeerView;
expanded: boolean;
onToggleExpand: () => void;
onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset') => void;
onRecheck: () => void;
recheckLoading: boolean;
defaultAutoRotateIntervalDays: number;
}) {
const [editingInterval, setEditingInterval] = useState(false);
@@ -562,6 +564,16 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo
{/* Actions */}
<div className="flex items-center gap-2">
{peer.status === 'unreachable' && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRecheck(); }}
disabled={recheckLoading}
className="px-3 py-1.5 text-xs font-medium bg-accent-mint/10 text-accent-mint hover:bg-accent-mint/20 rounded transition-colors disabled:opacity-50"
>
{recheckLoading ? 'Checking…' : 'Check now'}
</button>
)}
{peer.status === 'needs_attention' ? (
<button
type="button"
@@ -830,6 +842,7 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
peer: FederationPeer;
} | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [recheckingId, setRecheckingId] = useState<string | null>(null);
const fetchPeers = useCallback(async () => {
setPeersLoading(true);
@@ -879,6 +892,26 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
? 'No revoked peers.'
: null;
const handleRecheck = async (peer: FederationPeer) => {
setRecheckingId(peer.id);
try {
const result = await api.federation.recheckPeer(peer.id);
const name = peer.instanceName || new URL(peer.origin).host;
if (result.recovered) {
setPeers((prev) => prev.map((p) =>
p.id === peer.id ? { ...p, status: 'active' } : p
));
addToast(`${name} is back online`, 'success', 3000);
} else {
addToast(`${name} is still unreachable`, 'warning', 3000);
}
} catch (err) {
addToast(err instanceof Error ? err.message : 'Recheck failed', 'warning', 3000);
} finally {
setRecheckingId(null);
}
};
const handleConfirmAction = async () => {
if (!confirmAction) return;
const { type, peer } = confirmAction;
@@ -1032,6 +1065,8 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
expanded={expandedPeerId === peer.id}
onToggleExpand={() => setExpandedPeerId(expandedPeerId === peer.id ? null : peer.id)}
onAction={(type) => setConfirmAction({ type, peer })}
onRecheck={() => handleRecheck(peer)}
recheckLoading={recheckingId === peer.id}
defaultAutoRotateIntervalDays={useSettingsStore.getState().instanceSettings?.defaultAutoRotateIntervalDays ?? 90}
/>
))}