Merge pull request 'dev' (#3) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
Syoul
2026-04-22 02:10:38 +02:00
3 changed files with 158 additions and 42 deletions
+94 -40
View File
@@ -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,12 +80,47 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
return <span className="text-red-500"></span>; return <span className="text-red-500"></span>;
}; };
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
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(url)}
>
<div className="min-w-0">
<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 && (
<span className="text-xs font-mono text-[#6b7280]">
{dot(result.state)}
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
</span>
)}
<button
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
</button>
</div>
</div>
);
};
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm" 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(); }} 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"> <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 */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -74,45 +130,40 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
<button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button> <button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button>
</div> </div>
{/* Nœuds connus */} {/* Nœuds connus (statiques) */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p> <p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p>
{knownNodes.map((node) => { {knownNodes.map((node) => (
const result = testResults.get(node.url); <NodeRow key={node.url} url={node.url} label={node.label} />
const isActive = inputUrl === node.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)}
>
<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>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
{result && (
<span className="text-xs font-mono text-[#6b7280]">
{dot(result.state)}
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); runTest(node.url); }}
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
>
Tester
</button>
</div>
</div>
);
})}
</div> </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)}
+2 -2
View File
@@ -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>
))} ))}
+62
View File
@@ -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);
}