feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user