From 5a1e354ae1791d194512a1ee3bec7e50c1a232da Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:02:49 +0200 Subject: [PATCH] feat(federation-ui): add needs_attention pill and Reset peering action - peerStatusLabel/Color/DotColor gain a 'needs_attention' case (rose). - StatusFilter row gains 'Needs Attention' toggle. - PeerRow hides Rotate/Revoke and shows 'Reset Peering' when status is needs_attention, plus an Auth Failures stat. - Parent panel routes 'reset' through a ConfirmDialog (danger variant) that spells out the destructive nature and the out-of-band re-peer step. - Client FederationPeer interface gains consecutiveAuthFailures (Task 2 extended the shared type but the web client's local mirror was stale). Codifies the manual 'delete both sides, re-peer' workaround as a first-class admin action. --- packages/web/src/api/client.ts | 1 + .../FederationPanel.tsx | 93 +++++++++++++------ 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index e72851af..daad7ea0 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -71,6 +71,7 @@ export interface FederationPeer { lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; + consecutiveAuthFailures: number; lastSyncedAt: number | null; createdAt: number; secretRotatedAt: number | null; diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index 2c811a7a..bf313a8d 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -175,6 +175,7 @@ function peerStatusColor(status: string): string { case 'unreachable': return 'bg-accent-amber/15 text-accent-amber'; case 'rejected': return 'bg-accent-rose/15 text-accent-rose'; case 'awaiting_approval': return 'bg-accent-amber/15 text-accent-amber'; + case 'needs_attention': return 'bg-accent-rose/15 text-accent-rose'; case 'revoked': return 'bg-white/5 text-txt-tertiary'; default: return 'bg-white/5 text-txt-tertiary'; } @@ -187,6 +188,7 @@ function peerStatusDotColor(status: string): string { case 'unreachable': return 'bg-accent-amber'; case 'rejected': return 'bg-accent-rose'; case 'awaiting_approval': return 'bg-accent-amber'; + case 'needs_attention': return 'bg-accent-rose'; default: return 'bg-txt-tertiary'; } } @@ -199,13 +201,14 @@ function peerStatusLabel(status: string): string { case 'rejected': return 'Rejected (auto-peering denied)'; case 'revoked': return 'Revoked'; case 'awaiting_approval': return 'Awaiting Approval'; + case 'needs_attention': return 'Needs Attention'; default: return status; } } type PeerView = 'active' | 'revoked'; type SortBy = 'name' | 'lastSeen' | 'dateAdded' | 'failures'; -type StatusFilter = 'active' | 'unreachable' | 'pending' | 'rejected' | 'awaiting_approval'; +type StatusFilter = 'active' | 'unreachable' | 'pending' | 'rejected' | 'awaiting_approval' | 'needs_attention'; // ─── Filter Dropdown ───────────────────────────────────────────────────────── @@ -267,7 +270,7 @@ function FilterDropdown({ {view === 'active' && ( <>
Status
- {(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval'] as StatusFilter[]).map((s) => ( + {(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval', 'needs_attention'] as StatusFilter[]).map((s) => ( ))}
@@ -386,7 +393,7 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo view: PeerView; expanded: boolean; onToggleExpand: () => void; - onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete') => void; + onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset') => void; defaultAutoRotateIntervalDays: number; }) { const [editingInterval, setEditingInterval] = useState(false); @@ -484,6 +491,14 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo {peer.consecutiveFailures ?? 0}
+ {peer.status === 'needs_attention' && ( +
+
Auth Failures
+
+ {peer.consecutiveAuthFailures} +
+
+ )}
Last Failure
{formatRelativeTime(peer.lastFailureAt)}
@@ -547,30 +562,42 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo {/* Actions */}
- - - {!editingInterval && ( + {peer.status === 'needs_attention' ? ( + ) : ( + <> + + + {!editingInterval && ( + + )} + )}
@@ -750,13 +777,15 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan const [peersLoading, setPeersLoading] = useState(false); const [peersError, setPeersError] = useState(''); const [view, setView] = useState('active'); - const [statusFilter, setStatusFilter] = useState>(new Set(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval'])); + const [statusFilter, setStatusFilter] = useState>( + new Set(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval', 'needs_attention']), + ); const [sortBy, setSortBy] = useState('name'); const [expandedPeerId, setExpandedPeerId] = useState(null); // Confirm dialog state (used in Task 10) const [confirmAction, setConfirmAction] = useState<{ - type: 'rotate' | 'revoke' | 'reinitiate' | 'delete'; + type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset'; peer: FederationPeer; } | null>(null); const [actionLoading, setActionLoading] = useState(false); @@ -855,6 +884,12 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan addToast('Peer permanently deleted', 'success', 2000); break; } + case 'reset': { + await api.federation.resetPeer(peer.id); + setPeers((prev) => prev.filter((p) => p.id !== peer.id)); + addToast(`Peering reset for ${peer.instanceName || peer.origin}`, 'success', 3000); + break; + } } } catch (err) { addToast(err instanceof Error ? err.message : 'Action failed', 'warning', 3000); @@ -891,6 +926,12 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan confirmLabel: 'Delete', variant: 'danger' as const, }; + case 'reset': return { + title: 'Reset Peering', + description: `Reset peering with ${name}? This deletes the local peer record and all pending outbox entries. You must re-initiate peering with the remote admin out of band after reset. This cannot be undone.`, + confirmLabel: 'Reset', + variant: 'danger' as const, + }; } })() : null;