diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
index 622d1b65..181a9127 100644
--- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
+++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
@@ -361,15 +361,45 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo
onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete') => void;
defaultAutoRotateIntervalDays: number;
}) {
+ const [editingInterval, setEditingInterval] = useState(false);
+ const [intervalDraft, setIntervalDraft] = useState(peer.autoRotateIntervalDays);
+ const [intervalSaving, setIntervalSaving] = useState(false);
+ const [intervalError, setIntervalError] = useState('');
+ const addToast = useUIStore((s) => s.addToast);
+
const name = peer.instanceName || new URL(peer.origin).host;
const isRevoked = view === 'revoked';
+ const isDefault = peer.autoRotateIntervalDays === defaultAutoRotateIntervalDays;
+
+ const handleSaveInterval = async () => {
+ if (intervalDraft < 1 || intervalDraft > 365) {
+ setIntervalError('Must be 1-365');
+ return;
+ }
+ setIntervalSaving(true);
+ setIntervalError('');
+ try {
+ const result = await api.federation.updatePeer(peer.id, { autoRotateIntervalDays: intervalDraft });
+ // Update peer in parent state via a re-fetch would be cleanest,
+ // but for responsiveness we update the peer object directly.
+ // This works because React re-renders from the parent's setPeers.
+ peer.autoRotateIntervalDays = result.peer.autoRotateIntervalDays;
+ setEditingInterval(false);
+ addToast('Rotation interval updated', 'success', 2000);
+ } catch (err) {
+ setIntervalError(err instanceof Error ? err.message : 'Failed to update');
+ } finally {
+ setIntervalSaving(false);
+ }
+ };
return (
-
-
+
+ {/* Compact row */}
+
@@ -393,6 +423,133 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo
{expanded ? '▾' : '▸'}
+
+ {/* Expanded details */}
+ {expanded && (
+
+
+ {isRevoked ? (
+ /* Revoked peer actions */
+
+
+
+
+ ) : (
+ <>
+ {/* Stats grid */}
+
+
+
Consecutive Failures
+
0 ? 'text-accent-amber font-medium' : 'text-txt-secondary'}`}>
+ {peer.consecutiveFailures ?? 0}
+
+
+
+
Last Failure
+
{formatRelativeTime(peer.lastFailureAt)}
+
+
+
Peered Since
+
{formatAbsoluteDate(peer.createdAt)}
+
+
+
+
+
+
Secret Rotated
+
{formatRelativeTime(peer.secretRotatedAt)}
+
+
+
Auto-Rotate
+ {editingInterval ? (
+
e.stopPropagation()}>
+ setIntervalDraft(parseInt(e.target.value, 10) || 0)}
+ className="input-standard w-16 py-0.5 text-xs"
+ disabled={intervalSaving}
+ autoFocus
+ />
+
+
+
+ ) : (
+
+ Every {peer.autoRotateIntervalDays}d
+ {isDefault && (default)}
+
+ )}
+ {intervalError &&
{intervalError}
}
+
+
+
Rotation Status
+
+ {peer.rotationInProgress ? 'In progress' : 'Idle'}
+
+
+
+
+ {/* Actions */}
+
+
+
+ {!editingInterval && (
+
+ )}
+
+ >
+ )}
+
+
+ )}
);
}