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' ? (