diff --git a/src/components/charts/AnalyticsChart.jsx b/src/components/charts/AnalyticsChart.jsx index a6fcc54..67499b7 100644 --- a/src/components/charts/AnalyticsChart.jsx +++ b/src/components/charts/AnalyticsChart.jsx @@ -53,6 +53,31 @@ export function ActivityTrendChart({ data = [] }) { ); } +export function LatencyTrendChart({ data = [] }) { + return ( + + + + + new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + minTickGap={40} + /> + + [`${value} ms`, 'Latency']} /> + + + + + ); +} + export function FeeTrendChart({ data = [] }) { return ( @@ -69,10 +94,11 @@ export function FeeTrendChart({ data = [] }) { ); } -export default function AnalyticsChart({ data = [] }) { +export default function AnalyticsChart({ data = [], latencyData = [] }) { return (
+ {latencyData.length > 0 && }
); diff --git a/src/components/dashboard/SystemHealth.jsx b/src/components/dashboard/SystemHealth.jsx index 11f410b..22078be 100644 --- a/src/components/dashboard/SystemHealth.jsx +++ b/src/components/dashboard/SystemHealth.jsx @@ -1,6 +1,7 @@ import React from "react"; import { useMonitoring } from "../../hooks/useMonitoring"; import { StatCard } from "./Card"; +import { LatencyTrendChart } from "../charts/AnalyticsChart"; function AlertRow({ alert, onClear }) { const color = @@ -45,9 +46,59 @@ function AlertRow({ alert, onClear }) { ); } +function ServiceStatus({ label, probe }) { + const color = + probe.status === "up" + ? "var(--green)" + : probe.status === "degraded" + ? "var(--amber)" + : "var(--red)"; + + return ( +
+
+ {label} +
+
+ {probe.status} +
+
+ {probe.latency != null ? `${probe.latency} ms` : probe.error || "unavailable"} +
+
+ Circuit breaker: {probe.breakerState} +
+
+ ); +} + export default function SystemHealth() { const { snapshot, score, alerts, errors, clearAlert, resetAlerts } = useMonitoring(); const memory = snapshot?.memory; + const networkHealth = snapshot?.networkHealth || []; + const latencyHistory = snapshot?.latencyHistory || []; + + const averageLatency = latencyHistory.length + ? Math.round(latencyHistory[latencyHistory.length - 1].latency) + : null; + + const openBreakers = networkHealth.reduce((count, network) => { + return ( + count + + (network.horizon.breakerState === "OPEN" ? 1 : 0) + + (network.soroban.breakerState === "OPEN" ? 1 : 0) + ); + }, 0); return (
@@ -62,7 +113,7 @@ export default function SystemHealth() {
-
+
+ 1200 ? "var(--amber)" : "var(--green)"} + /> +
+ +
+ + + + +
+ + {networkHealth.length > 0 && ( +
+
Network Probes
+
+ {networkHealth.map((network) => ( +
+
+ {network.name} +
+
+ + +
+
+ ))} +
+
+ )} + +
+
Latency Trend
+
collectHealthSnapshot()); + const [snapshot, setSnapshot] = useState(() => ({ + ...collectHealthSnapshot(), + networkHealth: [], + latencyHistory: [], + })); const [errors, setErrors] = useState([]); const [alerts, setAlerts] = useState([]); @@ -16,13 +21,31 @@ export function useMonitoring(pollIntervalMs = 15000) { setErrors((prev) => [error, ...prev].slice(0, 30)); }); - const id = setInterval(() => { - setSnapshot(collectHealthSnapshot()); - }, pollIntervalMs); + let active = true; + + const refreshSnapshot = async () => { + setSnapshot((current) => ({ + ...current, + ...collectHealthSnapshot(), + })); + + try { + const systemSnapshot = await collectSystemHealthSnapshot(); + if (!active) return; + setSnapshot(systemSnapshot); + } catch (error) { + if (!active) return; + console.warn('Unable to refresh system health snapshot:', error); + } + }; + + refreshSnapshot(); + const id = setInterval(refreshSnapshot, pollIntervalMs); const unsubscribeAlerts = alertCenter.subscribe((items) => setAlerts(items)); return () => { + active = false; stopErrorWatch(); clearInterval(id); unsubscribeAlerts(); diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index ec000cd..1a9e940 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -246,6 +246,122 @@ export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.S ) } +export type ProbeStatus = 'up' | 'degraded' | 'down' + +export interface ServiceProbeResult { + url: string + status: ProbeStatus + latency: number | null + statusCode?: number + breakerState: CircuitState + error?: string +} + +export interface NetworkProbeResult { + network: NetworkName + name: string + horizon: ServiceProbeResult + soroban: ServiceProbeResult +} + +const PROBE_TIMEOUT_MS = 10_000 +const PROBE_LATENCY_DEGRADED_MS = 1_200 + +function resolveProbeStatus(response: Response, latency: number): ProbeStatus { + if (response.ok) { + return latency > PROBE_LATENCY_DEGRADED_MS ? 'degraded' : 'up' + } + if (response.status >= 500) { + return 'down' + } + return 'degraded' +} + +async function probeServiceUrl( + network: NetworkName, + url: string, + serviceLabel: 'horizon' | 'soroban' +): Promise { + const serviceName = `${serviceLabel}:${network}` + const breaker = getCircuitBreaker(serviceName, { + failureThreshold: 4, + timeout: 15_000, + }) + + if (!url) { + return { + url, + status: 'down', + latency: null, + breakerState: breaker.currentState, + error: 'URL unavailable', + } + } + + const start = Date.now() + let response: Response | null = null + + try { + response = await breaker.execute(async () => { + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + try { + const headResponse = await rateLimitedFetch( + url, + { method: 'HEAD', cache: 'no-store', signal: controller.signal }, + 'low', + ) + + if (headResponse.status === 405 || headResponse.status === 501) { + return await rateLimitedFetch( + url, + { method: 'GET', cache: 'no-store', signal: controller.signal }, + 'low', + ) + } + + return headResponse + } finally { + window.clearTimeout(timeoutId) + } + }) + + const latency = Date.now() - start + return { + url, + status: resolveProbeStatus(response, latency), + latency, + statusCode: response.status, + breakerState: breaker.currentState, + } + } catch (error) { + return { + url, + status: 'down', + latency: null, + breakerState: breaker.currentState, + error: String(error), + } + } +} + +export async function probeAllNetworks(): Promise { + const probeKeys = Object.entries(NETWORKS) as [NetworkName, NetworkConfig][] + const probes = probeKeys.map(async ([network, config]) => { + const horizon = await probeServiceUrl(network, config.horizonUrl, 'horizon') + const soroban = await probeServiceUrl(network, config.sorobanUrl || '', 'soroban') + + return { + network, + name: config.name, + horizon, + soroban, + } + }) + + return Promise.all(probes) +} + // ─── Account ────────────────────────────────────────────────────────────────── export async function fetchAccount( diff --git a/src/utils/monitoring.js b/src/utils/monitoring.js index 7867c21..4871a7e 100644 --- a/src/utils/monitoring.js +++ b/src/utils/monitoring.js @@ -1,3 +1,5 @@ +import { probeAllNetworks } from "../lib/stellar"; + function nowIso() { return new Date().toISOString(); } @@ -32,6 +34,48 @@ export function collectHealthSnapshot() { }; } +const MAX_LATENCY_POINTS = 96 +const latencyHistory = [] + +function recordLatencySample(latency) { + if (!Number.isFinite(latency) || latency === null) return + latencyHistory.push({ timestamp: nowIso(), latency }) + const cutoff = Date.now() - 24 * 60 * 60 * 1000 + while (latencyHistory.length && Date.parse(latencyHistory[0].timestamp) < cutoff) { + latencyHistory.shift() + } + while (latencyHistory.length > MAX_LATENCY_POINTS) { + latencyHistory.shift() + } +} + +export async function collectSystemHealthSnapshot() { + const snapshot = collectHealthSnapshot() + let networkHealth = [] + + try { + networkHealth = await probeAllNetworks() + + const horizonLatencies = networkHealth + .map((network) => network.horizon.latency) + .filter((latency) => Number.isFinite(latency)) + + if (horizonLatencies.length) { + const avgLatency = + horizonLatencies.reduce((sum, value) => sum + value, 0) / horizonLatencies.length + recordLatencySample(avgLatency) + } + } catch (error) { + console.warn('Network probe failed:', error) + } + + return { + ...snapshot, + networkHealth, + latencyHistory: [...latencyHistory], + } +} + export function computeHealthScore(snapshot) { if (!snapshot) return 0; let score = 100;