refactor(web): extract federation section from GeneralPanel
This commit is contained in:
@@ -1,22 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSettingsStore } from '../../../stores/settingsStore';
|
import { useSettingsStore } from '../../../stores/settingsStore';
|
||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
import { api } from '../../../api/client';
|
|
||||||
import type { InstanceAdminSettings } from '@backspace/shared';
|
import type { InstanceAdminSettings } from '@backspace/shared';
|
||||||
|
|
||||||
interface FederationPeer {
|
|
||||||
id: string;
|
|
||||||
origin: string;
|
|
||||||
instanceName: string | null;
|
|
||||||
status: string;
|
|
||||||
lastSeenAt: number | null;
|
|
||||||
lastFailureAt: number | null;
|
|
||||||
consecutiveFailures: number | null;
|
|
||||||
lastSyncedAt: number | null;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneralPanel() {
|
export function GeneralPanel() {
|
||||||
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
|
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
|
||||||
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
|
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
|
||||||
@@ -29,11 +16,6 @@ export function GeneralPanel() {
|
|||||||
const [gifKeyDirty, setGifKeyDirty] = useState(false);
|
const [gifKeyDirty, setGifKeyDirty] = useState(false);
|
||||||
const [gifKeyDraft, setGifKeyDraft] = useState('');
|
const [gifKeyDraft, setGifKeyDraft] = useState('');
|
||||||
|
|
||||||
// Federation peers state
|
|
||||||
const [peers, setPeers] = useState<FederationPeer[]>([]);
|
|
||||||
const [peersLoading, setPeersLoading] = useState(false);
|
|
||||||
const [revokingPeerId, setRevokingPeerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (instanceSettings) {
|
if (instanceSettings) {
|
||||||
setDraft({ ...instanceSettings });
|
setDraft({ ...instanceSettings });
|
||||||
@@ -42,30 +24,12 @@ export function GeneralPanel() {
|
|||||||
}
|
}
|
||||||
}, [instanceSettings]);
|
}, [instanceSettings]);
|
||||||
|
|
||||||
const fetchPeers = useCallback(async () => {
|
|
||||||
setPeersLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await api.federation.peers();
|
|
||||||
setPeers(result.peers);
|
|
||||||
} catch {
|
|
||||||
// Non-critical — peers list may fail on instances without federation routes
|
|
||||||
} finally {
|
|
||||||
setPeersLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPeers();
|
|
||||||
}, [fetchPeers]);
|
|
||||||
|
|
||||||
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||||
|
|
||||||
const baseChanges = instanceSettings && draft
|
const baseChanges = instanceSettings && draft
|
||||||
? draft.instanceName !== instanceSettings.instanceName ||
|
? draft.instanceName !== instanceSettings.instanceName ||
|
||||||
draft.registrationOpen !== instanceSettings.registrationOpen ||
|
draft.registrationOpen !== instanceSettings.registrationOpen ||
|
||||||
draft.discoveryEnabled !== instanceSettings.discoveryEnabled ||
|
draft.discoveryEnabled !== instanceSettings.discoveryEnabled
|
||||||
draft.federationRelayEnabled !== instanceSettings.federationRelayEnabled ||
|
|
||||||
draft.federationRelayTtlDays !== instanceSettings.federationRelayTtlDays
|
|
||||||
: false;
|
: false;
|
||||||
const hasChanges = baseChanges || gifKeyDirty;
|
const hasChanges = baseChanges || gifKeyDirty;
|
||||||
|
|
||||||
@@ -77,8 +41,6 @@ export function GeneralPanel() {
|
|||||||
instanceName: draft!.instanceName,
|
instanceName: draft!.instanceName,
|
||||||
registrationOpen: draft!.registrationOpen,
|
registrationOpen: draft!.registrationOpen,
|
||||||
discoveryEnabled: draft!.discoveryEnabled,
|
discoveryEnabled: draft!.discoveryEnabled,
|
||||||
federationRelayEnabled: draft!.federationRelayEnabled,
|
|
||||||
federationRelayTtlDays: draft!.federationRelayTtlDays,
|
|
||||||
};
|
};
|
||||||
if (gifKeyDirty) {
|
if (gifKeyDirty) {
|
||||||
payload.gifApiKey = gifKeyDraft;
|
payload.gifApiKey = gifKeyDraft;
|
||||||
@@ -101,44 +63,6 @@ export function GeneralPanel() {
|
|||||||
setSaveError('');
|
setSaveError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRevokePeer = async (peerId: string) => {
|
|
||||||
setRevokingPeerId(peerId);
|
|
||||||
try {
|
|
||||||
await api.federation.revokePeer(peerId);
|
|
||||||
setPeers((prev) => prev.filter((p) => p.id !== peerId));
|
|
||||||
addToast('Peer revoked', 'success', 2000);
|
|
||||||
} catch (err) {
|
|
||||||
addToast(err instanceof Error ? err.message : 'Failed to revoke peer', 'warning', 3000);
|
|
||||||
} finally {
|
|
||||||
setRevokingPeerId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRelativeTime = (timestamp: number | null): string => {
|
|
||||||
if (!timestamp) return 'Never';
|
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
||||||
if (seconds < 60) return 'Just now';
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const peerStatusColor = (status: string): string => {
|
|
||||||
switch (status) {
|
|
||||||
case 'active': return 'bg-status-online/15 text-status-online';
|
|
||||||
case 'pending': return 'bg-accent-amber/15 text-accent-amber';
|
|
||||||
case 'unreachable': return 'bg-accent-rose/15 text-txt-danger';
|
|
||||||
case 'revoked': return 'bg-white/5 text-txt-tertiary';
|
|
||||||
default: return 'bg-white/5 text-txt-tertiary';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only show non-revoked peers
|
|
||||||
const visiblePeers = peers.filter((p) => p.status !== 'revoked');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
|
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
|
||||||
<h2 className="text-lg font-semibold text-txt-primary">General</h2>
|
<h2 className="text-lg font-semibold text-txt-primary">General</h2>
|
||||||
@@ -223,94 +147,6 @@ export function GeneralPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Federation */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Federation</div>
|
|
||||||
<p className="text-xs text-txt-tertiary mb-2">
|
|
||||||
Control DM relay between federated instances. When enabled, DMs with users on peer instances are relayed server-to-server.
|
|
||||||
</p>
|
|
||||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
|
||||||
<label className="flex items-center justify-between cursor-pointer">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-txt-primary">Enable DM Relay</div>
|
|
||||||
<div className="text-xs text-txt-tertiary mt-0.5">Relay direct messages to and from peer instances</div>
|
|
||||||
</div>
|
|
||||||
<Toggle enabled={draft.federationRelayEnabled} onChange={(v) => setDraft({ ...draft, federationRelayEnabled: v })} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-txt-primary mb-1">Relay TTL (days)</div>
|
|
||||||
<div className="text-xs text-txt-tertiary mb-2">How long relayed messages are retained in the outbox before cleanup</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={365}
|
|
||||||
value={draft.federationRelayTtlDays}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(val) && val >= 1 && val <= 365) {
|
|
||||||
setDraft({ ...draft, federationRelayTtlDays: val });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="input-standard w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Peers list */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="text-sm font-medium text-txt-primary">Peered Instances</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={fetchPeers}
|
|
||||||
disabled={peersLoading}
|
|
||||||
className="text-[11px] text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{peersLoading ? 'Loading...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visiblePeers.length === 0 && !peersLoading && (
|
|
||||||
<div className="text-xs text-txt-tertiary py-2">
|
|
||||||
No federation peers configured. Peers are created automatically when connecting to remote instances.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visiblePeers.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{visiblePeers.map((peer) => (
|
|
||||||
<div key={peer.id} className="flex items-center justify-between rounded-md bg-white/[0.02] px-3 py-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-txt-primary truncate">
|
|
||||||
{peer.instanceName || new URL(peer.origin).host}
|
|
||||||
</span>
|
|
||||||
<span className={`inline-flex items-center text-[10px] font-medium px-1.5 py-0.5 rounded ${peerStatusColor(peer.status)}`}>
|
|
||||||
{peer.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-txt-tertiary truncate">{peer.origin}</div>
|
|
||||||
<div className="text-[11px] text-txt-tertiary">
|
|
||||||
Last seen: {formatRelativeTime(peer.lastSeenAt)}
|
|
||||||
{peer.lastSyncedAt ? ` · Synced: ${formatRelativeTime(peer.lastSyncedAt)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRevokePeer(peer.id)}
|
|
||||||
disabled={revokingPeerId === peer.id}
|
|
||||||
className="ml-3 px-2.5 py-1 text-xs font-medium text-txt-danger bg-accent-rose/10 hover:bg-accent-rose/20 rounded transition-colors disabled:opacity-50 flex-shrink-0"
|
|
||||||
>
|
|
||||||
{revokingPeerId === peer.id ? 'Revoking...' : 'Revoke'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status messages */}
|
{/* Status messages */}
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user