dev #3
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
KNOWN_SUBSQUID_NODES,
|
KNOWN_SUBSQUID_NODES,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
setSubsquidUrl,
|
setSubsquidUrl,
|
||||||
setCesiumUrl,
|
setCesiumUrl,
|
||||||
} from '../services/EndpointConfig';
|
} from '../services/EndpointConfig';
|
||||||
|
import { discoverSquidNodes, clearPeerCache } from '../services/PeerDiscovery';
|
||||||
import { testEndpoint } from '../hooks/useServiceStatus';
|
import { testEndpoint } from '../hooks/useServiceStatus';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,8 +31,11 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
const [inputUrl, setInputUrl] = useState(currentUrl);
|
const [inputUrl, setInputUrl] = useState(currentUrl);
|
||||||
const [testResults, setTestResults] = useState<Map<string, TestResult>>(new Map());
|
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 }));
|
setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null }));
|
||||||
try {
|
try {
|
||||||
const ms = await testEndpoint(service, url);
|
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 handleSave = () => {
|
||||||
const trimmed = inputUrl.trim();
|
const trimmed = inputUrl.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -59,40 +80,22 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
|||||||
return <span className="text-red-500">●</span>;
|
return <span className="text-red-500">●</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
const NodeRow = ({ url, label }: { url: string; label?: string }) => {
|
||||||
<div
|
const result = testResults.get(url);
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
const isActive = inputUrl === url;
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
const hostname = label ?? (() => { try { return new URL(url).hostname; } catch { return url; } })();
|
||||||
>
|
|
||||||
<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;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={node.url}
|
|
||||||
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
|
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
|
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
|
||||||
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
|
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setInputUrl(node.url)}
|
onClick={() => setInputUrl(url)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-white text-sm font-medium truncate">{node.label}</p>
|
<p className="text-white text-sm font-medium truncate">{hostname}</p>
|
||||||
<p className="text-[#4b5563] text-xs font-mono truncate">{node.url}</p>
|
<p className="text-[#4b5563] text-xs font-mono truncate">{url}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-3 shrink-0">
|
<div className="flex items-center gap-2 ml-3 shrink-0">
|
||||||
{result && (
|
{result && (
|
||||||
@@ -102,7 +105,7 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<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"
|
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
|
||||||
>
|
>
|
||||||
Tester
|
Tester
|
||||||
@@ -110,9 +113,57 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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 */}
|
{/* URL personnalisée */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">URL personnalisée</p>
|
<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"
|
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
|
<button
|
||||||
onClick={() => runTest(inputUrl.trim())}
|
onClick={() => testUrl(inputUrl.trim())}
|
||||||
disabled={!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"
|
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>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const result = testResults.get(inputUrl.trim());
|
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 (
|
return (
|
||||||
<p className="text-xs font-mono text-[#6b7280] pl-1">
|
<p className="text-xs font-mono text-[#6b7280] pl-1">
|
||||||
{dot(result.state)}
|
{dot(result.state)}
|
||||||
|
|||||||
@@ -330,8 +330,8 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
style={{ cursor: 'default' }}
|
style={{ cursor: 'default' }}
|
||||||
>
|
>
|
||||||
{/* Zone de hit invisible plus large */}
|
{/* 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="transparent" strokeWidth={Math.max(2, a.strokeW)} pointerEvents="stroke" />
|
||||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" />
|
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" pointerEvents="stroke" />
|
||||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||||
</g>
|
</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