diff --git a/src/components/EndpointPopover.tsx b/src/components/EndpointPopover.tsx index cb32350..3054373 100644 --- a/src/components/EndpointPopover.tsx +++ b/src/components/EndpointPopover.tsx @@ -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>(new Map()); + const [discoveredUrls, setDiscoveredUrls] = useState([]); + 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,12 +80,47 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) { return ; }; + 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 ( +
setInputUrl(url)} + > +
+

{hostname}

+

{url}

+
+
+ {result && ( + + {dot(result.state)} + {result.latencyMs !== null && ` ${result.latencyMs} ms`} + + )} + +
+
+ ); + }; + return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} > -
+
{/* Header */}
@@ -74,45 +130,40 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
- {/* Nœuds connus */} + {/* Nœuds connus (statiques) */}

Nœuds connus

- {knownNodes.map((node) => { - const result = testResults.get(node.url); - const isActive = inputUrl === node.url; - return ( -
setInputUrl(node.url)} - > -
-

{node.label}

-

{node.url}

-
-
- {result && ( - - {dot(result.state)} - {result.latencyMs !== null && ` ${result.latencyMs} ms`} - - )} - -
-
- ); - })} + {knownNodes.map((node) => ( + + ))}
+ {/* Nœuds découverts via duniter_peerings (SubSquid uniquement) */} + {service === 'subsquid' && ( +
+
+

Réseau Ğ1

+ +
+ {discovering && discoveredUrls.length === 0 && ( +

Découverte en cours…

+ )} + {!discovering && discoveredUrls.length === 0 && ( +

Aucun nœud trouvé

+ )} + {discoveredUrls.map((url) => ( + + ))} +
+ )} + {/* URL personnalisée */}

URL personnalisée

@@ -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" />
{(() => { 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 (

{dot(result.state)} diff --git a/src/components/FlowMap.tsx b/src/components/FlowMap.tsx index 37feaf5..f4fe62a 100644 --- a/src/components/FlowMap.tsx +++ b/src/components/FlowMap.tsx @@ -330,7 +330,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { style={{ cursor: 'default' }} > {/* Zone de hit invisible plus large */} - + diff --git a/src/services/PeerDiscovery.ts b/src/services/PeerDiscovery.ts new file mode 100644 index 0000000..e9c02d5 --- /dev/null +++ b/src/services/PeerDiscovery.ts @@ -0,0 +1,62 @@ +const DUNITER_RPC = 'https://rpc.duniter.org'; +const CACHE_KEY = 'geoflux-peers-v1'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +interface PeerCache { + urls: string[]; + fetchedAt: number; +} + +function normalizeSquidUrl(raw: string): string { + const url = raw.replace(/\/$/, ''); + return url.endsWith('/v1/graphql') ? url : `${url}/v1/graphql`; +} + +export async function discoverSquidNodes(): Promise { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + const parsed: PeerCache = JSON.parse(cached); + if (Date.now() - parsed.fetchedAt < CACHE_TTL_MS) return parsed.urls; + } + } catch { /* ignore */ } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8_000); + try { + const res = await fetch(DUNITER_RPC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'duniter_peerings', params: [], id: 1 }), + signal: controller.signal, + }); + const data = await res.json(); + const peers: { peer_id: string; endpoints: { protocol: string; address: string }[] }[] = + data?.result?.peerings ?? []; + + const seen = new Set(); + const urls: string[] = []; + for (const peer of peers) { + for (const ep of peer.endpoints ?? []) { + if (ep.protocol === 'squid' && ep.address) { + const normalized = normalizeSquidUrl(ep.address); + if (!seen.has(normalized)) { + seen.add(normalized); + urls.push(normalized); + } + } + } + } + + localStorage.setItem(CACHE_KEY, JSON.stringify({ urls, fetchedAt: Date.now() })); + return urls; + } catch { + return []; + } finally { + clearTimeout(timer); + } +} + +export function clearPeerCache(): void { + localStorage.removeItem(CACHE_KEY); +}