@@ -1,4 +1,4 @@
import { useState } from 'react' ;
import { useState , useEffect } from 'react' ;
import { createPortal } from 'react-dom' ;
import {
KNOWN_SUBSQUID_NODES ,
@@ -8,6 +8,7 @@ import {
setSubsquidUrl ,
setCesiumUrl ,
} from '../services/EndpointConfig' ;
import { discoverSquidNodes , clearPeerCache } from '../services/PeerDiscovery' ;
import { testEndpoint } from '../hooks/useServiceStatus' ;
interface Props {
@@ -30,8 +31,11 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
const [ inputUrl , setInputUrl ] = useState ( currentUrl ) ;
const [ testResults , setTestResults ] = useState < Map < string , TestResult > > ( new Map ( ) ) ;
const [ discoveredUrls , setDiscoveredUrls ] = useState < string [ ] > ( [ ] ) ;
const [ discovering , setDiscovering ] = useState ( false ) ;
const [ discoverVersion , setDiscoverVersion ] = useState ( 0 ) ;
const runTest = async ( url : string ) = > {
const testUrl = async ( url : string ) = > {
setTestResults ( ( prev ) = > new Map ( prev ) . set ( url , { url , state : 'testing' , latencyMs : null } ) ) ;
try {
const ms = await testEndpoint ( service , url ) ;
@@ -43,6 +47,23 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
}
} ;
// Découverte des nœuds réseau (SubSquid uniquement)
useEffect ( ( ) = > {
if ( service !== 'subsquid' ) return ;
setDiscovering ( true ) ;
setDiscoveredUrls ( [ ] ) ;
discoverSquidNodes ( ) . then ( ( urls ) = > {
setDiscoveredUrls ( urls ) ;
setDiscovering ( false ) ;
urls . forEach ( ( url ) = > testUrl ( url ) ) ;
} ) ;
} , [ discoverVersion ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
const refreshDiscovery = ( ) = > {
clearPeerCache ( ) ;
setDiscoverVersion ( ( v ) = > v + 1 ) ;
} ;
const handleSave = ( ) = > {
const trimmed = inputUrl . trim ( ) ;
if ( ! trimmed ) return ;
@@ -59,40 +80,22 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
return < span className = "text-red-500" > ● < / span > ;
} ;
return createPortal (
< div
className = "fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick = { ( e ) = > { if ( e . target === e . currentTarget ) onClose ( ) ; } }
>
< div className = "bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5" >
{ /* Header */ }
< div className = "flex items-center justify-between" >
< h2 className = "text-white font-bold text-base" >
Configurer { LABELS [ service ] }
< / h2 >
< button onClick = { onClose } className = "text-[#4b5563] hover:text-white transition-colors text-xl leading-none" > × < / button >
< / div >
{ /* Nœuds connus */ }
< div className = "space-y-2" >
< p className = "text-[#4b5563] text-xs uppercase tracking-widest" > Nœuds connus < / p >
{ knownNodes . map ( ( node ) = > {
const result = testResults . get ( node . url ) ;
const isActive = inputUrl === node . url ;
const NodeRow = ( { url , label } : { url : string ; label? : string } ) = > {
const result = testResults . get ( url ) ;
const isActive = inputUrl === url ;
const hostname = label ? ? ( ( ) = > { try { return new URL ( url ) . hostname ; } catch { return url ; } } ) ( ) ;
return (
< div
key = { node . url }
className = { ` flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
isActive
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
} ` }
onClick = { ( ) = > setInputUrl ( node . url ) }
onClick = { ( ) = > setInputUrl ( url ) }
>
< div className = "min-w-0" >
< p className = "text-white text-sm font-medium truncate" > { node . label } < / p >
< p className = "text-[#4b5563] text-xs font-mono truncate" > { node . url } < / p >
< p className = "text-white text-sm font-medium truncate" > { hostname } < / p >
< p className = "text-[#4b5563] text-xs font-mono truncate" > { url } < / p >
< / div >
< div className = "flex items-center gap-2 ml-3 shrink-0" >
{ result && (
@@ -102,7 +105,7 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
< / span >
) }
< button
onClick = { ( e ) = > { e . stopPropagation ( ) ; runTest ( node . url ) ; } }
onClick = { ( e ) = > { e . stopPropagation ( ) ; testUrl ( url ) ; } }
className = "text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
>
Tester
@@ -110,9 +113,57 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
< / div >
< / div >
) ;
} ) }
} ;
return createPortal (
< div
className = "fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick = { ( e ) = > { if ( e . target === e . currentTarget ) onClose ( ) ; } }
>
< div className = "bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5 max-h-[85vh] overflow-y-auto" >
{ /* Header */ }
< div className = "flex items-center justify-between" >
< h2 className = "text-white font-bold text-base" >
Configurer { LABELS [ service ] }
< / h2 >
< button onClick = { onClose } className = "text-[#4b5563] hover:text-white transition-colors text-xl leading-none" > × < / button >
< / div >
{ /* Nœuds connus (statiques) */ }
< div className = "space-y-2" >
< p className = "text-[#4b5563] text-xs uppercase tracking-widest" > Nœuds connus < / p >
{ knownNodes . map ( ( node ) = > (
< NodeRow key = { node . url } url = { node . url } label = { node . label } / >
) ) }
< / div >
{ /* Nœuds découverts via duniter_peerings (SubSquid uniquement) */ }
{ service === 'subsquid' && (
< div className = "space-y-2" >
< div className = "flex items-center justify-between" >
< p className = "text-[#4b5563] text-xs uppercase tracking-widest" > Réseau Ğ 1 < / p >
< button
onClick = { refreshDiscovery }
disabled = { discovering }
className = "text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-40 transition-colors"
title = "Actualiser la liste des nœuds"
>
{ discovering ? < span className = "animate-spin inline-block" > ↻ < / span > : '↻ Actualiser' }
< / button >
< / div >
{ discovering && discoveredUrls . length === 0 && (
< p className = "text-xs text-[#4b5563] pl-1" > Découverte en cours … < / p >
) }
{ ! discovering && discoveredUrls . length === 0 && (
< p className = "text-xs text-[#4b5563] pl-1" > Aucun nœud trouvé < / p >
) }
{ discoveredUrls . map ( ( url ) = > (
< NodeRow key = { url } url = { url } / >
) ) }
< / div >
) }
{ /* URL personnalisée */ }
< div className = "space-y-2" >
< p className = "text-[#4b5563] text-xs uppercase tracking-widest" > URL personnalisée < / p >
@@ -125,7 +176,7 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
className = "flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors"
/ >
< button
onClick = { ( ) = > runTest ( inputUrl . trim ( ) ) }
onClick = { ( ) = > testUrl ( inputUrl . trim ( ) ) }
disabled = { ! inputUrl . trim ( ) }
className = "text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-30 transition-colors px-3 py-2 border border-[#2e2f3a] rounded-xl"
>
@@ -134,7 +185,10 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
< / div >
{ ( ( ) = > {
const result = testResults . get ( inputUrl . trim ( ) ) ;
if ( ! result || knownNodes . some ( ( n ) = > n . url === inputUrl . trim ( ) ) ) return null ;
const isKnown = [ . . . knownNodes , . . . discoveredUrls . map ( ( u ) = > ( { url : u } ) ) ] . some (
( n ) = > n . url === inputUrl . trim ( )
) ;
if ( ! result || isKnown ) return null ;
return (
< p className = "text-xs font-mono text-[#6b7280] pl-1" >
{ dot ( result . state ) }