feat: real-time Federation panel updates via WS events
Added federation_peers_changed (no-payload signal) broadcast from every peer state mutation, and federation_approval_request_received when a new approval request is queued. Client subscribes via onFederationPeersChanged callback registry. FederationPanel and PendingApprovals debounce-refetch on any event. sendToAdmins helper broadcasts only to admin users.
This commit is contained in:
@@ -469,6 +469,13 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify admin users that a new approval request arrived
|
||||||
|
connectionManager.sendToAdmins({
|
||||||
|
type: 'federation_approval_request_received' as const,
|
||||||
|
origin: sourceOrigin,
|
||||||
|
instanceName: reqInstanceName ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(202).send({
|
return reply.code(202).send({
|
||||||
queued: true,
|
queued: true,
|
||||||
message: 'Request queued for admin approval',
|
message: 'Request queued for admin approval',
|
||||||
@@ -514,6 +521,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
return reply.code(200).send({ accepted: true });
|
return reply.code(200).send({ accepted: true });
|
||||||
}
|
}
|
||||||
if (existing.status === 'awaiting_approval') {
|
if (existing.status === 'awaiting_approval') {
|
||||||
@@ -535,6 +544,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
return reply.code(200).send({ accepted: true });
|
return reply.code(200).send({ accepted: true });
|
||||||
}
|
}
|
||||||
// Pending — update with new secret and activate
|
// Pending — update with new secret and activate
|
||||||
@@ -547,6 +558,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.federationPeers.id, existing.id))
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
return reply.code(200).send({ accepted: true });
|
return reply.code(200).send({ accepted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +574,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
return reply.code(200).send({ accepted: true });
|
return reply.code(200).send({ accepted: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -733,6 +748,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.federationPeers.id, peer.id))
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
// Push federation_peer_rejected WS event to affected users
|
// Push federation_peer_rejected WS event to affected users
|
||||||
const entries = db
|
const entries = db
|
||||||
.select({
|
.select({
|
||||||
@@ -1041,6 +1058,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.peerApprovalRequests.id, id))
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
const peer = db
|
const peer = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.federationPeers)
|
.from(schema.federationPeers)
|
||||||
@@ -1137,6 +1156,8 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
db.delete(schema.peerApprovalRequests)
|
db.delete(schema.peerApprovalRequests)
|
||||||
.where(eq(schema.peerApprovalRequests.id, id))
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ async function performHandshake(
|
|||||||
.set({ status: 'awaiting_approval' })
|
.set({ status: 'awaiting_approval' })
|
||||||
.where(eq(schema.federationPeers.id, peerId))
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
.run();
|
.run();
|
||||||
|
const { connectionManager } = await import('../ws/handler.js');
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
return { status: 'pending', error: 'Awaiting admin approval on remote instance' };
|
return { status: 'pending', error: 'Awaiting admin approval on remote instance' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +156,8 @@ async function performHandshake(
|
|||||||
.set({ status: 'active', lastSeenAt: Date.now() })
|
.set({ status: 'active', lastSeenAt: Date.now() })
|
||||||
.where(eq(schema.federationPeers.id, peerId))
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
.run();
|
.run();
|
||||||
|
const { connectionManager } = await import('../ws/handler.js');
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
return { status: 'active', peerId };
|
return { status: 'active', peerId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +178,8 @@ async function performHandshake(
|
|||||||
.set({ status: 'rejected' })
|
.set({ status: 'rejected' })
|
||||||
.where(eq(schema.federationPeers.id, peerId))
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
.run();
|
.run();
|
||||||
|
const { connectionManager } = await import('../ws/handler.js');
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
return { status: 'rejected', error: errorMessage };
|
return { status: 'rejected', error: errorMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ async function processOutboxTick(): Promise<void> {
|
|||||||
})
|
})
|
||||||
.where(eq(schema.federationPeers.id, peerId))
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
.run();
|
.run();
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[federation-worker] Peer ${peerOrigin} returned HTTP ${response.status}`,
|
`[federation-worker] Peer ${peerOrigin} returned HTTP ${response.status}`,
|
||||||
@@ -388,6 +389,8 @@ async function resolvePendingPeers(): Promise<void> {
|
|||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
console.log(`[federation-worker] Auto-peered with ${peerOrigin} — entries will deliver next tick`);
|
console.log(`[federation-worker] Auto-peered with ${peerOrigin} — entries will deliver next tick`);
|
||||||
|
// Notify admins of peer state change
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'rejected': {
|
case 'rejected': {
|
||||||
@@ -417,6 +420,8 @@ async function resolvePendingPeers(): Promise<void> {
|
|||||||
|
|
||||||
// Push federation_peer_rejected WS event to affected users
|
// Push federation_peer_rejected WS event to affected users
|
||||||
pushPeerRejectedEvent(peerOrigin, contextMap);
|
pushPeerRejectedEvent(peerOrigin, contextMap);
|
||||||
|
// Notify admins of peer state change
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -876,6 +876,18 @@ class ConnectionManager {
|
|||||||
return this.connections;
|
return this.connections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send an event to all connected admin users. */
|
||||||
|
sendToAdmins(event: ServerEvent): void {
|
||||||
|
const db = getDb();
|
||||||
|
for (const userId of this.connections.keys()) {
|
||||||
|
const user = db.select({ isAdmin: schema.users.isAdmin })
|
||||||
|
.from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||||
|
if (user?.isAdmin === 1) {
|
||||||
|
this.sendToUser(userId, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Push a fresh ready payload to a specific user, forcing full store re-sync. */
|
/** Push a fresh ready payload to a specific user, forcing full store re-sync. */
|
||||||
pushReadyPayload(userId: string): void {
|
pushReadyPayload(userId: string): void {
|
||||||
const connections = this.getUserConnections(userId);
|
const connections = this.getUserConnections(userId);
|
||||||
|
|||||||
@@ -446,6 +446,8 @@ export type ServerEvent =
|
|||||||
| { type: 'federation_file_rejected'; messageId: string; dmChannelId: string; attachmentId: string; affectedUsers: Array<{ userId: string; username: string; limit: number }> }
|
| { type: 'federation_file_rejected'; messageId: string; dmChannelId: string; attachmentId: string; affectedUsers: Array<{ userId: string; username: string; limit: number }> }
|
||||||
| { type: 'federation_peer_rejected'; peerOrigin: string; peerLabel?: string; reason: string; affectedContexts: Array<{ contextType: 'dm' | 'friend'; contextId: string; contextLabel: string }> }
|
| { type: 'federation_peer_rejected'; peerOrigin: string; peerLabel?: string; reason: string; affectedContexts: Array<{ contextType: 'dm' | 'friend'; contextId: string; contextLabel: string }> }
|
||||||
| { type: 'federation_peer_active'; peerOrigin: string }
|
| { type: 'federation_peer_active'; peerOrigin: string }
|
||||||
|
| { type: 'federation_peers_changed' }
|
||||||
|
| { type: 'federation_approval_request_received'; origin: string; instanceName?: string }
|
||||||
| { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string }
|
| { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useUIStore } from '../../../stores/uiStore';
|
|||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
||||||
import { api } from '../../../api/client';
|
import { api } from '../../../api/client';
|
||||||
|
import { onFederationPeersChanged } from '../../../hooks/useWebSocket';
|
||||||
import type { InstanceAdminSettings } from '@backspace/shared';
|
import type { InstanceAdminSettings } from '@backspace/shared';
|
||||||
import type { FederationPeer, ApprovalRequest } from '../../../api/client';
|
import type { FederationPeer, ApprovalRequest } from '../../../api/client';
|
||||||
|
|
||||||
@@ -611,6 +612,18 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) =
|
|||||||
fetchRequests();
|
fetchRequests();
|
||||||
}, [fetchRequests]);
|
}, [fetchRequests]);
|
||||||
|
|
||||||
|
// Real-time updates: re-fetch approval requests on federation changes
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
const unsub = onFederationPeersChanged(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
return () => { unsub(); clearTimeout(timeout); };
|
||||||
|
}, [fetchRequests]);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!confirmAction) return;
|
if (!confirmAction) return;
|
||||||
const { type, request: req } = confirmAction;
|
const { type, request: req } = confirmAction;
|
||||||
@@ -765,6 +778,18 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
|
|||||||
fetchPeers();
|
fetchPeers();
|
||||||
}, [fetchPeers]);
|
}, [fetchPeers]);
|
||||||
|
|
||||||
|
// Real-time updates: re-fetch peers and approval requests on any federation change
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
const unsub = onFederationPeersChanged(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
fetchPeers();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
return () => { unsub(); clearTimeout(timeout); };
|
||||||
|
}, [fetchPeers]);
|
||||||
|
|
||||||
// Derived peer lists
|
// Derived peer lists
|
||||||
const activePeers = peers.filter((p) => p.status !== 'revoked');
|
const activePeers = peers.filter((p) => p.status !== 'revoked');
|
||||||
const revokedPeers = peers.filter((p) => p.status === 'revoked');
|
const revokedPeers = peers.filter((p) => p.status === 'revoked');
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ export function getActivePeerOrigins(): Set<string> {
|
|||||||
return activePeerOrigins;
|
return activePeerOrigins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Federation change listeners (for real-time panel updates) ───────────────
|
||||||
|
const federationChangeListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function onFederationPeersChanged(cb: () => void): () => void {
|
||||||
|
federationChangeListeners.add(cb);
|
||||||
|
return () => { federationChangeListeners.delete(cb); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyFederationChangeListeners(): void {
|
||||||
|
for (const cb of federationChangeListeners) cb();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Connection state ─────────────────────────────────────────────────────────
|
// ─── Connection state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConnectionState {
|
interface ConnectionState {
|
||||||
@@ -717,6 +729,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
'warning',
|
'warning',
|
||||||
10000,
|
10000,
|
||||||
);
|
);
|
||||||
|
notifyFederationChangeListeners();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,6 +737,17 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
rejectedPeerOrigins.delete(event.peerOrigin);
|
rejectedPeerOrigins.delete(event.peerOrigin);
|
||||||
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
||||||
activePeerOrigins.add(event.peerOrigin);
|
activePeerOrigins.add(event.peerOrigin);
|
||||||
|
notifyFederationChangeListeners();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'federation_peers_changed': {
|
||||||
|
notifyFederationChangeListeners();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'federation_approval_request_received': {
|
||||||
|
notifyFederationChangeListeners();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user