Compare commits
4 Commits
a36a6729e3
...
f81ff92e0e
| Author | SHA1 | Date | |
|---|---|---|---|
| f81ff92e0e | |||
| dfe832728e | |||
| 782b063b25 | |||
| 88e2232cfb |
@@ -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)}
|
||||
|
||||
@@ -330,8 +330,8 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
{/* Zone de hit invisible plus large */}
|
||||
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(12, a.strokeW + 8)} />
|
||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" />
|
||||
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(2, a.strokeW)} pointerEvents="stroke" />
|
||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" pointerEvents="stroke" />
|
||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user