From 0d9415ae6ae20ea45fb61b127f8ef359d0f1177f Mon Sep 17 00:00:00 2001 From: syoul Date: Tue, 21 Apr 2026 20:43:33 +0200 Subject: [PATCH] feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dots de statut en temps réel dans le StatsPanel (ok/slow/error + latence) - Bannière d'alerte si un service est inaccessible - EndpointPopover : sélection parmi nœuds connus, test de latence live, URL custom - Rechargement automatique des données après changement d'endpoint - SubsquidAdapter et CesiumAdapter lisent l'URL active depuis EndpointConfig - InfoPanel mis à jour (overlay DU + statut des services) Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 4 +- src/components/EndpointPopover.tsx | 170 +++++++++++++++++++++++ src/components/InfoPanel.tsx | 18 ++- src/components/ServiceStatusDots.tsx | 87 ++++++++++++ src/components/StatsPanel.tsx | 12 +- src/hooks/useServiceStatus.ts | 118 ++++++++++++++++ src/services/EndpointConfig.ts | 35 +++++ src/services/adapters/CesiumAdapter.ts | 5 +- src/services/adapters/SubsquidAdapter.ts | 9 +- 9 files changed, 448 insertions(+), 10 deletions(-) create mode 100644 src/components/EndpointPopover.tsx create mode 100644 src/components/ServiceStatusDots.tsx create mode 100644 src/hooks/useServiceStatus.ts create mode 100644 src/services/EndpointConfig.ts diff --git a/src/App.tsx b/src/App.tsx index 8366fa6..52a282d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ export default function App() { const [showMembers, setShowMembers] = useState(false); const [memberCities, setMemberCities] = useState([]); const [membersLoading, setMembersLoading] = useState(false); + const [endpointVersion, setEndpointVersion] = useState(0); const isMobile = useMediaQuery('(max-width: 639px)'); const toggleMembers = async () => { @@ -119,7 +120,7 @@ export default function App() { const interval = setInterval(() => load(false), 30_000); return () => { cancelled = true; clearInterval(interval); }; - }, [periodDays]); + }, [periodDays, endpointVersion]); // Stats heatmap sur la fenêtre courante en mode animation const visibleStats: PeriodStats | null = animation.active @@ -150,6 +151,7 @@ export default function App() { flowStats, focusCity, allTimestamps, + onEndpointChange: () => setEndpointVersion((v) => v + 1), }; return ( diff --git a/src/components/EndpointPopover.tsx b/src/components/EndpointPopover.tsx new file mode 100644 index 0000000..cb32350 --- /dev/null +++ b/src/components/EndpointPopover.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + KNOWN_SUBSQUID_NODES, + KNOWN_CESIUM_NODES, + getSubsquidUrl, + getCesiumUrl, + setSubsquidUrl, + setCesiumUrl, +} from '../services/EndpointConfig'; +import { testEndpoint } from '../hooks/useServiceStatus'; + +interface Props { + service: 'subsquid' | 'cesium'; + onClose: () => void; + onSaved: () => void; +} + +interface TestResult { + url: string; + state: 'testing' | 'ok' | 'slow' | 'error'; + latencyMs: number | null; +} + +const LABELS = { subsquid: 'SubSquid', cesium: 'Cesium+' }; + +export function EndpointPopover({ service, onClose, onSaved }: Props) { + const currentUrl = service === 'subsquid' ? getSubsquidUrl() : getCesiumUrl(); + const knownNodes = service === 'subsquid' ? KNOWN_SUBSQUID_NODES : KNOWN_CESIUM_NODES; + + const [inputUrl, setInputUrl] = useState(currentUrl); + const [testResults, setTestResults] = useState>(new Map()); + + const runTest = async (url: string) => { + setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null })); + try { + const ms = await testEndpoint(service, url); + setTestResults((prev) => + new Map(prev).set(url, { url, state: ms < 2000 ? 'ok' : 'slow', latencyMs: ms }) + ); + } catch { + setTestResults((prev) => new Map(prev).set(url, { url, state: 'error', latencyMs: null })); + } + }; + + const handleSave = () => { + const trimmed = inputUrl.trim(); + if (!trimmed) return; + if (service === 'subsquid') setSubsquidUrl(trimmed); + else setCesiumUrl(trimmed); + onSaved(); + onClose(); + }; + + const dot = (state: TestResult['state']) => { + if (state === 'testing') return ; + if (state === 'ok') return ; + if (state === 'slow') return ; + return ; + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ + {/* Header */} +
+

+ Configurer {LABELS[service]} +

+ +
+ + {/* Nœuds connus */} +
+

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`} + + )} + +
+
+ ); + })} +
+ + {/* URL personnalisée */} +
+

URL personnalisée

+
+ setInputUrl(e.target.value)} + placeholder={service === 'subsquid' ? 'https://…/v1/graphql' : 'https://…'} + 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; + return ( +

+ {dot(result.state)} + {result.state === 'testing' && ' Test en cours…'} + {result.state === 'ok' && ` OK · ${result.latencyMs} ms`} + {result.state === 'slow' && ` Lent · ${result.latencyMs} ms`} + {result.state === 'error' && ' Inaccessible'} +

+ ); + })()} +
+ + {/* Actions */} +
+ + +
+
+
, + document.body + ); +} diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx index 6c52a8e..96399de 100644 --- a/src/components/InfoPanel.tsx +++ b/src/components/InfoPanel.tsx @@ -127,9 +127,25 @@ export function InfoPanel({ onClose }: InfoPanelProps) { +
+ + Le bouton DU (à gauche de la carte) affiche en overlay les membres Ğ1 + actifs (WoT) ayant un profil Cesium+ géolocalisé. + Chaque point représente une ville avec des membres actifs. + +
+
- Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes. + Données temps réel de la blockchain Ğ1v2 via SubSquid, actualisées toutes les 30 secondes. + Les profils de géolocalisation sont fournis par Cesium+. + + + Deux indicateurs en bas du panneau latéral affichent l'état de SubSquid et Cesium+ en temps réel + (vert OK ·{' '} + jaune lent ·{' '} + rouge inaccessible). + Un clic sur un indicateur permet de configurer ou changer l'endpoint.
diff --git a/src/components/ServiceStatusDots.tsx b/src/components/ServiceStatusDots.tsx new file mode 100644 index 0000000..477b0fd --- /dev/null +++ b/src/components/ServiceStatusDots.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { ServiceStatus, ServiceState } from '../hooks/useServiceStatus'; +import { EndpointPopover } from './EndpointPopover'; + +interface Props { + subsquid: ServiceStatus; + cesium: ServiceStatus; + onEndpointChange: () => void; +} + +const STATE_COLOR: Record = { + checking: 'text-[#4b5563] animate-pulse', + ok: 'text-emerald-400', + slow: 'text-amber-400', + error: 'text-red-500', +}; + +function Dot({ status, label, onClick }: { status: ServiceStatus; label: string; onClick: () => void }) { + const latency = status.latencyMs !== null ? ` · ${status.latencyMs} ms` : ''; + const title = `${label} — ${STATUS_LABEL_FULL[status.state]}${latency}\n${status.url}`; + + return ( + + ); +} + +const STATUS_LABEL_FULL: Record = { + checking: 'Vérification…', + ok: 'Accessible', + slow: 'Réponse lente', + error: 'Inaccessible', +}; + +export function ServiceStatusDots({ subsquid, cesium, onEndpointChange }: Props) { + const [popover, setPopover] = useState<'subsquid' | 'cesium' | null>(null); + + const hasError = subsquid.state === 'error' || cesium.state === 'error'; + const erroredService = subsquid.state === 'error' ? 'SubSquid' : 'Cesium+'; + const erroredKey: 'subsquid' | 'cesium' = subsquid.state === 'error' ? 'subsquid' : 'cesium'; + + return ( + <> + {/* Bannière d'erreur */} + {hasError && ( +
+ + + {erroredService} inaccessible + + +
+ )} + + {/* Dots */} +
+ setPopover('subsquid')} /> + setPopover('cesium')} /> +
+ + {/* Popover de configuration */} + {popover && ( + setPopover(null)} + onSaved={onEndpointChange} + /> + )} + + ); +} diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index 53bd3ad..364ea64 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -2,6 +2,8 @@ import { useRef } from 'react'; import type { PeriodStats } from '../services/DataService'; import type { FlowStats } from '../data/arcData'; import { Sparkline } from './Sparkline'; +import { ServiceStatusDots } from './ServiceStatusDots'; +import { useServiceStatus } from '../hooks/useServiceStatus'; interface StatsPanelProps { stats: PeriodStats | null; @@ -15,6 +17,7 @@ interface StatsPanelProps { flowStats?: FlowStats | null; focusCity?: string | null; onClose?: () => void; + onEndpointChange?: () => void; allTimestamps?: number[]; } @@ -62,7 +65,8 @@ function CityRow({ city, volume, count, countryCode, accent }: { ); } -export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className, allTimestamps = [] }: StatsPanelProps) { +export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [] }: StatsPanelProps) { + const { subsquid, cesium, recheck } = useServiceStatus(); const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -97,7 +101,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim Ğ1Flux v{__APP_VERSION__} -

Monnaie libre · Flux géo

+ { recheck(); onEndpointChange?.(); }} + /> {onClose && (