dev #1

Merged
syoul merged 5 commits from dev into main 2026-04-21 20:52:41 +02:00
9 changed files with 448 additions and 10 deletions
Showing only changes of commit 0d9415ae6a - Show all commits
+3 -1
View File
@@ -34,6 +34,7 @@ export default function App() {
const [showMembers, setShowMembers] = useState(false); const [showMembers, setShowMembers] = useState(false);
const [memberCities, setMemberCities] = useState<MemberCity[]>([]); const [memberCities, setMemberCities] = useState<MemberCity[]>([]);
const [membersLoading, setMembersLoading] = useState(false); const [membersLoading, setMembersLoading] = useState(false);
const [endpointVersion, setEndpointVersion] = useState(0);
const isMobile = useMediaQuery('(max-width: 639px)'); const isMobile = useMediaQuery('(max-width: 639px)');
const toggleMembers = async () => { const toggleMembers = async () => {
@@ -119,7 +120,7 @@ export default function App() {
const interval = setInterval(() => load(false), 30_000); const interval = setInterval(() => load(false), 30_000);
return () => { cancelled = true; clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]); }, [periodDays, endpointVersion]);
// Stats heatmap sur la fenêtre courante en mode animation // Stats heatmap sur la fenêtre courante en mode animation
const visibleStats: PeriodStats | null = animation.active const visibleStats: PeriodStats | null = animation.active
@@ -150,6 +151,7 @@ export default function App() {
flowStats, flowStats,
focusCity, focusCity,
allTimestamps, allTimestamps,
onEndpointChange: () => setEndpointVersion((v) => v + 1),
}; };
return ( return (
+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> </Feature>
</Section> </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"> <Section title="Source de données">
<Feature icon="●" name="Live Ğ1v2"> <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> </Feature>
</Section> </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 { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData'; import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline'; import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus';
interface StatsPanelProps { interface StatsPanelProps {
stats: PeriodStats | null; stats: PeriodStats | null;
@@ -15,6 +17,7 @@ interface StatsPanelProps {
flowStats?: FlowStats | null; flowStats?: FlowStats | null;
focusCity?: string | null; focusCity?: string | null;
onClose?: () => void; onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[]; 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 periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null); const prevStats = useRef<PeriodStats | null>(null);
@@ -97,7 +101,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
Ğ1Flux Ğ1Flux
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span> <span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
</h1> </h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p> <ServiceStatusDots
subsquid={subsquid}
cesium={cesium}
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
/>
</div> </div>
{onClose && ( {onClose && (
<button <button
+118
View File
@@ -0,0 +1,118 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getSubsquidUrl, getCesiumUrl } from '../services/EndpointConfig';
export type ServiceState = 'checking' | 'ok' | 'slow' | 'error';
export interface ServiceStatus {
state: ServiceState;
latencyMs: number | null;
url: string;
}
export interface ServicesStatus {
subsquid: ServiceStatus;
cesium: ServiceStatus;
recheck: () => void;
}
const TIMEOUT_MS = 8_000;
const SLOW_THRESHOLD_MS = 2_000;
const POLL_INTERVAL_MS = 30_000;
async function pingSubsquid(url: string): Promise<number> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await res.json();
return Date.now() - start;
} finally {
clearTimeout(timer);
}
}
async function pingCesium(url: string): Promise<number> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(`${url}/user/profile/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 0, query: { match_all: {} } }),
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return Date.now() - start;
} finally {
clearTimeout(timer);
}
}
function latencyToState(ms: number): ServiceState {
if (ms < SLOW_THRESHOLD_MS) return 'ok';
return 'slow';
}
const CHECKING: ServiceStatus = { state: 'checking', latencyMs: null, url: '' };
export function useServiceStatus(): ServicesStatus {
const [subsquid, setSubsquid] = useState<ServiceStatus>(CHECKING);
const [cesium, setCesium] = useState<ServiceStatus>(CHECKING);
const [version, setVersion] = useState(0);
const cancelled = useRef(false);
const runChecks = useCallback(async () => {
const squidUrl = getSubsquidUrl();
const cesiumUrl = getCesiumUrl();
setSubsquid({ state: 'checking', latencyMs: null, url: squidUrl });
setCesium( { state: 'checking', latencyMs: null, url: cesiumUrl });
const [squidResult, cesiumResult] = await Promise.allSettled([
pingSubsquid(squidUrl),
pingCesium(cesiumUrl),
]);
if (cancelled.current) return;
if (squidResult.status === 'fulfilled') {
const ms = squidResult.value;
setSubsquid({ state: latencyToState(ms), latencyMs: ms, url: squidUrl });
} else {
setSubsquid({ state: 'error', latencyMs: null, url: squidUrl });
}
if (cesiumResult.status === 'fulfilled') {
const ms = cesiumResult.value;
setCesium({ state: latencyToState(ms), latencyMs: ms, url: cesiumUrl });
} else {
setCesium({ state: 'error', latencyMs: null, url: cesiumUrl });
}
}, [version]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
cancelled.current = false;
runChecks();
const interval = setInterval(runChecks, POLL_INTERVAL_MS);
return () => {
cancelled.current = true;
clearInterval(interval);
};
}, [runChecks]);
const recheck = useCallback(() => setVersion((v) => v + 1), []);
return { subsquid, cesium, recheck };
}
export async function testEndpoint(type: 'subsquid' | 'cesium', url: string): Promise<number> {
return type === 'subsquid' ? pingSubsquid(url) : pingCesium(url);
}
+35
View File
@@ -0,0 +1,35 @@
const STORAGE_KEY = {
subsquid: 'geoflux-ep-subsquid',
cesium: 'geoflux-ep-cesium',
} as const;
export const DEFAULT_ENDPOINTS = {
subsquid: 'https://squidv2s.syoul.fr/v1/graphql',
cesium: 'https://g1.data.e-is.pro',
} as const;
export const KNOWN_SUBSQUID_NODES: { label: string; url: string }[] = [
{ label: 'squidv2s.syoul.fr (défaut)', url: 'https://squidv2s.syoul.fr/v1/graphql' },
];
export const KNOWN_CESIUM_NODES: { label: string; url: string }[] = [
{ label: 'g1.data.e-is.pro (défaut)', url: 'https://g1.data.e-is.pro' },
];
export function getSubsquidUrl(): string {
return localStorage.getItem(STORAGE_KEY.subsquid) ?? DEFAULT_ENDPOINTS.subsquid;
}
export function getCesiumUrl(): string {
return localStorage.getItem(STORAGE_KEY.cesium) ?? DEFAULT_ENDPOINTS.cesium;
}
export function setSubsquidUrl(url: string): void {
if (url === DEFAULT_ENDPOINTS.subsquid) localStorage.removeItem(STORAGE_KEY.subsquid);
else localStorage.setItem(STORAGE_KEY.subsquid, url);
}
export function setCesiumUrl(url: string): void {
if (url === DEFAULT_ENDPOINTS.cesium) localStorage.removeItem(STORAGE_KEY.cesium);
else localStorage.setItem(STORAGE_KEY.cesium, url);
}
+3 -2
View File
@@ -12,6 +12,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { getCesiumUrl } from '../EndpointConfig';
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro'; export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
@@ -136,7 +137,7 @@ export async function resolveGeoByKeys(
_source: ['title', 'city', 'geoPoint'], _source: ['title', 'city', 'geoPoint'],
}; };
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, { const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query), body: JSON.stringify(query),
@@ -210,7 +211,7 @@ export async function resolveGeoByNames(
_source: ['title', 'city', 'geoPoint'], _source: ['title', 'city', 'geoPoint'],
}; };
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, { const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query), body: JSON.stringify(query),
+5 -4
View File
@@ -12,6 +12,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { getSubsquidUrl } from '../EndpointConfig';
export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql'; export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
@@ -136,7 +137,7 @@ const IDENTITY_KEY_MAP_QUERY = `
* car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+ * car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+
*/ */
export async function buildIdentityKeyMap(): Promise<Map<string, string>> { export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
const response = await fetch(SUBSQUID_ENDPOINT, { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }), body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }),
@@ -157,7 +158,7 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
export async function fetchCurrentUD(): Promise<number> { export async function fetchCurrentUD(): Promise<number> {
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
try { try {
const response = await fetch(SUBSQUID_ENDPOINT, { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -193,7 +194,7 @@ const ACTIVE_MEMBERS_QUERY = `
/** Retourne la liste des clés SS58 de tous les membres WoT actifs. */ /** Retourne la liste des clés SS58 de tous les membres WoT actifs. */
export async function fetchActiveMemberKeys(): Promise<string[]> { export async function fetchActiveMemberKeys(): Promise<string[]> {
const res = await fetch(SUBSQUID_ENDPOINT, { const res = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: ACTIVE_MEMBERS_QUERY }), body: JSON.stringify({ query: ACTIVE_MEMBERS_QUERY }),
@@ -222,7 +223,7 @@ export async function fetchTransfers(
Date.now() - periodDays * 24 * 60 * 60 * 1000 Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString(); ).toISOString();
const response = await fetch(SUBSQUID_ENDPOINT, { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({