feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+
ci/woodpecker/push/woodpecker Pipeline was successful

- 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 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-04-21 20:43:33 +02:00
parent 7c9d626b98
commit 0d9415ae6a
9 changed files with 448 additions and 10 deletions
+170
View File
@@ -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<Map<string, TestResult>>(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 <span className="text-[#6b7280] animate-pulse"></span>;
if (state === 'ok') return <span className="text-emerald-400"></span>;
if (state === 'slow') return <span className="text-amber-400"></span>;
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;
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>
{/* URL personnalisée */}
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">URL personnalisée</p>
<div className="flex gap-2">
<input
type="url"
value={inputUrl}
onChange={(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"
/>
<button
onClick={() => runTest(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"
>
Tester
</button>
</div>
{(() => {
const result = testResults.get(inputUrl.trim());
if (!result || knownNodes.some((n) => n.url === inputUrl.trim())) return null;
return (
<p className="text-xs font-mono text-[#6b7280] pl-1">
{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'}
</p>
);
})()}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[#4b5563] hover:text-white border border-[#2e2f3a] rounded-xl transition-colors"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={!inputUrl.trim()}
className="px-4 py-2 text-sm font-medium bg-[#d4a843] text-[#0a0b0f] rounded-xl hover:bg-[#e0b84d] disabled:opacity-30 transition-colors"
>
Utiliser
</button>
</div>
</div>
</div>,
document.body
);
}
+17 -1
View File
@@ -127,9 +127,25 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
</Feature>
</Section>
<Section title="Overlay Dividende Universel">
<Feature icon="DU" name="Membres actifs géolocalisés">
Le bouton <Kbd>DU</Kbd> (à 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.
</Feature>
</Section>
<Section title="Source de données">
<Feature icon="●" name="Live Ğ1v2">
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+.
</Feature>
<Feature icon="●" name="Statut des services">
Deux indicateurs en bas du panneau latéral affichent l'état de SubSquid et Cesium+ en temps réel
(<span className="text-emerald-400">vert</span> OK ·{' '}
<span className="text-amber-400">jaune</span> lent ·{' '}
<span className="text-red-400">rouge</span> inaccessible).
Un clic sur un indicateur permet de configurer ou changer l'endpoint.
</Feature>
</Section>
+87
View File
@@ -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<ServiceState, string> = {
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 (
<button
onClick={onClick}
title={title}
className="flex items-center gap-1 text-[10px] font-mono text-[#4b5563] hover:text-white transition-colors group"
>
<span className={STATE_COLOR[status.state]}></span>
<span className="group-hover:text-[#6b7280]">
{label}
{status.latencyMs !== null && (
<span className="text-[#2e2f3a] ml-0.5">{status.latencyMs}ms</span>
)}
</span>
</button>
);
}
const STATUS_LABEL_FULL: Record<ServiceState, string> = {
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 && (
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 text-xs">
<span className="text-red-400">
<span className="text-red-500 mr-1"></span>
{erroredService} inaccessible
</span>
<button
onClick={() => setPopover(erroredKey)}
className="text-[#d4a843] hover:text-[#e0b84d] transition-colors font-medium ml-2"
>
Configurer
</button>
</div>
)}
{/* Dots */}
<div className="flex items-center gap-3">
<Dot status={subsquid} label="SubSquid" onClick={() => setPopover('subsquid')} />
<Dot status={cesium} label="Cesium+" onClick={() => setPopover('cesium')} />
</div>
{/* Popover de configuration */}
{popover && (
<EndpointPopover
service={popover}
onClose={() => setPopover(null)}
onSaved={onEndpointChange}
/>
)}
</>
);
}
+10 -2
View File
@@ -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<PeriodStats | null>(null);
@@ -97,7 +101,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
Ğ1Flux
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
</h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
<ServiceStatusDots
subsquid={subsquid}
cesium={cesium}
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
/>
</div>
{onClose && (
<button