feat(web): Check now button to manually recover unreachable federation peers
This commit is contained in:
@@ -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 }) =>
|
||||
|
||||
+48
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user