diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts
index acb47ba6..8445bb75 100644
--- a/packages/web/src/api/client.ts
+++ b/packages/web/src/api/client.ts
@@ -277,6 +277,7 @@ export class BackspaceApiClient {
peers: () => Promise<{ peers: FederationPeer[] }>;
revokePeer: (id: string) => Promise<{ success: boolean }>;
resetPeer: (id: string) => Promise<{ success: boolean }>;
+ recheckPeer: (id: string) => Promise<{ recovered: boolean; status: string }>;
rotatePeerSecret: (id: string) => Promise<{ success: boolean; gracePeriodMs: number }>;
updatePeer: (id: string, data: { autoRotateIntervalDays: number }) => Promise<{ peer: FederationPeer }>;
deletePeerPermanently: (id: string) => Promise<{ success: boolean }>;
@@ -705,6 +706,8 @@ export class BackspaceApiClient {
request<{ success: boolean }>('DELETE', `/federation/peers/${id}`),
resetPeer: (id: string) =>
request<{ success: boolean }>('POST', `/federation/peers/${id}/reset`),
+ recheckPeer: (id: string) =>
+ request<{ recovered: boolean; status: string }>('POST', `/federation/peers/${id}/recheck`),
rotatePeerSecret: (id: string) =>
request<{ success: boolean; gracePeriodMs: number }>('POST', `/federation/peers/${id}/rotate`),
updatePeer: (id: string, data: { autoRotateIntervalDays: number }) =>
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx
new file mode 100644
index 00000000..5f2c0f46
--- /dev/null
+++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx
@@ -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();
+ // 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));
+ });
+});
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
index 1edfdaf4..6dcc04d3 100644
--- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
+++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
@@ -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 */}
+ {peer.status === 'unreachable' && (
+
+ )}
{peer.status === 'needs_attention' ? (