feat: add turn-ready node p2p config

This commit is contained in:
lpf
2026-03-08 23:12:29 +08:00
parent 3db78e0577
commit f441972c56
13 changed files with 384 additions and 29 deletions

View File

@@ -107,6 +107,48 @@ const Config: React.FC = () => {
setCfg((v) => setPath(v, `providers.proxies.${name}.${field}`, value));
}
function updateGatewayP2PField(field: string, value: any) {
setCfg((v) => setPath(v, `gateway.nodes.p2p.${field}`, value));
}
function updateGatewayIceServer(index: number, field: string, value: any) {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {};
if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {};
if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {};
if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = [];
if (!next.gateway.nodes.p2p.ice_servers[index] || typeof next.gateway.nodes.p2p.ice_servers[index] !== 'object') {
next.gateway.nodes.p2p.ice_servers[index] = { urls: [], username: '', credential: '' };
}
next.gateway.nodes.p2p.ice_servers[index][field] = value;
return next;
});
}
function addGatewayIceServer() {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {};
if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {};
if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {};
if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = [];
next.gateway.nodes.p2p.ice_servers.push({ urls: [], username: '', credential: '' });
return next;
});
}
function removeGatewayIceServer(index: number) {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
const iceServers = next?.gateway?.nodes?.p2p?.ice_servers;
if (Array.isArray(iceServers)) {
iceServers.splice(index, 1);
}
return next;
});
}
async function removeProxy(name: string) {
const ok = await ui.confirmDialog({
title: t('configDeleteProviderConfirmTitle'),
@@ -291,6 +333,77 @@ const Config: React.FC = () => {
</div>
</div>
)}
{activeTop === 'gateway' && !showRaw && (
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configNodeP2P')}</div>
<div className="text-xs text-zinc-500">{t('configNodeP2PHint')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('enable')}</div>
<input
type="checkbox"
checked={Boolean((cfg as any)?.gateway?.nodes?.p2p?.enabled)}
onChange={(e) => updateGatewayP2PField('enabled', e.target.checked)}
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('dashboardNodeP2PTransport')}</div>
<select
value={String((cfg as any)?.gateway?.nodes?.p2p?.transport || 'websocket_tunnel')}
onChange={(e) => updateGatewayP2PField('transport', e.target.value)}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
>
<option value="websocket_tunnel">websocket_tunnel</option>
<option value="webrtc">webrtc</option>
</select>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('dashboardNodeP2PIce')}</div>
<input
value={Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.stun_servers) ? (cfg as any).gateway.nodes.p2p.stun_servers.join(', ') : ''}
onChange={(e) => updateGatewayP2PField('stun_servers', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder={t('configNodeP2PStunPlaceholder')}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
</label>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-medium text-zinc-200">{t('configNodeP2PIceServers')}</div>
<button onClick={addGatewayIceServer} className="brand-button px-2 py-1 rounded-lg text-xs text-white">{t('add')}</button>
</div>
{Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.ice_servers) && (cfg as any).gateway.nodes.p2p.ice_servers.length > 0 ? (
((cfg as any).gateway.nodes.p2p.ice_servers as Array<any>).map((server, index) => (
<div key={`ice-${index}`} className="grid grid-cols-1 md:grid-cols-7 gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 p-2 text-xs">
<input
value={Array.isArray(server?.urls) ? server.urls.join(', ') : ''}
onChange={(e) => updateGatewayIceServer(index, 'urls', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder={t('configNodeP2PIceUrlsPlaceholder')}
className="md:col-span-3 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
<input
value={String(server?.username || '')}
onChange={(e) => updateGatewayIceServer(index, 'username', e.target.value)}
placeholder={t('configNodeP2PIceUsername')}
className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
<input
value={String(server?.credential || '')}
onChange={(e) => updateGatewayIceServer(index, 'credential', e.target.value)}
placeholder={t('configNodeP2PIceCredential')}
className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
<button onClick={() => removeGatewayIceServer(index)} className="md:col-span-1 px-2 py-1 rounded bg-red-900/60 hover:bg-red-800 text-red-100">{t('delete')}</button>
</div>
))
) : (
<div className="text-xs text-zinc-500">{t('configNodeP2PIceServersEmpty')}</div>
)}
</div>
</div>
)}
{activeTop && activeTop !== hotReloadTabKey ? (
<RecursiveConfig
data={(cfg as any)?.[activeTop] || {}}

View File

@@ -40,6 +40,11 @@ const Dashboard: React.FC = () => {
const p2pEnabled = Boolean(nodeP2P?.enabled);
const p2pTransport = String(nodeP2P?.transport || (p2pEnabled ? 'enabled' : 'disabled'));
const p2pSessions = Number(nodeP2P?.active_sessions || 0);
const p2pConfiguredStun = Array.isArray(nodeP2P?.configured_stun) ? nodeP2P.configured_stun.length : 0;
const p2pConfiguredIce = Number(nodeP2P?.configured_ice || nodeP2P?.ice_servers || 0);
const p2pRetryCount = Array.isArray(nodeP2P?.nodes)
? nodeP2P.nodes.reduce((sum: number, session: any) => sum + Number(session?.retry_count || 0), 0)
: 0;
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6 xl:space-y-8">
@@ -78,10 +83,14 @@ const Dashboard: React.FC = () => {
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
<div className="flex items-center gap-2 text-zinc-200 mb-2">
<Workflow className="w-4 h-4 text-sky-400" />
<div className="text-sm font-medium">{t('ekgTopProvidersWorkload')}</div>
<div className="text-sm font-medium">{t('nodeP2P')}</div>
</div>
<div className="text-2xl font-semibold text-zinc-100 truncate">
{p2pEnabled ? `${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN` : t('disabled')}
</div>
<div className="mt-2 text-xs text-zinc-500">
{t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })}
</div>
<div className="text-2xl font-semibold text-zinc-100 truncate">{ekgTopProvider}</div>
<div className="mt-2 text-xs text-zinc-500">{t('dashboardWorkloadSnapshot')}</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
<div className="flex items-center gap-2 text-zinc-200 mb-2">
@@ -120,19 +129,22 @@ const Dashboard: React.FC = () => {
<div className="brand-card rounded-[30px] border border-zinc-800/80 p-6 min-h-[340px] h-full">
<div className="flex items-center gap-2 mb-5 text-zinc-200">
<AlertTriangle className="w-5 h-5 text-zinc-400" />
<h2 className="text-lg font-medium">{t('statusError')}</h2>
<Workflow className="w-5 h-5 text-zinc-400" />
<h2 className="text-lg font-medium">{t('nodeP2P')}</h2>
</div>
<div className="space-y-3">
{recentFailures.length === 0 ? (
<div className="text-sm text-zinc-500 text-center py-10">-</div>
) : recentFailures.map((task: any, index: number) => (
<div key={`${task.task_id || 'failed'}-${index}`} className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-sm font-medium text-zinc-200 truncate">{task.task_id || `task-${index + 1}`}</div>
<div className="text-xs text-zinc-500 truncate mt-1">{task.source || '-'} · {task.channel || '-'}</div>
<div className="text-xs text-rose-300 mt-2 break-all">{task.error || task.block_reason || '-'}</div>
</div>
))}
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-sm font-medium text-zinc-200">{t('dashboardNodeP2PTransport')}</div>
<div className="text-xs text-zinc-500 mt-1">{p2pTransport}</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-sm font-medium text-zinc-200">{t('dashboardNodeP2PIce')}</div>
<div className="text-xs text-zinc-500 mt-1">{`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`}</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-sm font-medium text-zinc-200">{t('dashboardNodeP2PHealth')}</div>
<div className="text-xs text-zinc-500 mt-1">{t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })}</div>
</div>
</div>
</div>
</div>