+
+ 1200 ? "var(--amber)" : "var(--green)"}
+ />
+
+
+
+
+
+
+
+
+
+ {networkHealth.length > 0 && (
+
+
Network Probes
+
+ {networkHealth.map((network) => (
+
+
+ {network.name}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
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;