Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/components/charts/AnalyticsChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ export function ActivityTrendChart({ data = [] }) {
);
}

export function LatencyTrendChart({ data = [] }) {
return (
<ChartShell title="Latency (Last 24h)">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
tickFormatter={(value) => new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
minTickGap={40}
/>
<YAxis
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
domain={[0, 'dataMax + 100']}
unit="ms"
/>
<Tooltip formatter={(value) => [`${value} ms`, 'Latency']} />
<Line dataKey="latency" stroke="var(--cyan)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartShell>
);
}

export function FeeTrendChart({ data = [] }) {
return (
<ChartShell title="Fees (Stroops)">
Expand All @@ -69,10 +94,11 @@ export function FeeTrendChart({ data = [] }) {
);
}

export default function AnalyticsChart({ data = [] }) {
export default function AnalyticsChart({ data = [], latencyData = [] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "16px" }}>
<ActivityTrendChart data={data} />
{latencyData.length > 0 && <LatencyTrendChart data={latencyData} />}
<FeeTrendChart data={data} />
</div>
);
Expand Down
115 changes: 114 additions & 1 deletion src/components/dashboard/SystemHealth.jsx
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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 (
<div
style={{
border: "1px solid var(--border)",
borderRadius: "var(--radius-md)",
background: "var(--bg-elevated)",
padding: "12px",
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
<div style={{ fontSize: "11px", color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.08em" }}>
{label}
</div>
<div style={{ fontSize: "16px", color, fontWeight: 700, textTransform: "uppercase" }}>
{probe.status}
</div>
<div style={{ fontSize: "11px", color: "var(--text-secondary)" }}>
{probe.latency != null ? `${probe.latency} ms` : probe.error || "unavailable"}
</div>
<div style={{ fontSize: "11px", color: "var(--text-muted)" }}>
Circuit breaker: {probe.breakerState}
</div>
</div>
);
}

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 (
<div className="animate-in" style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
Expand All @@ -62,7 +113,7 @@ export default function SystemHealth() {
<StatCard label="Runtime Errors" value={errors.length} accent={errors.length ? "var(--amber)" : "var(--cyan)"} />
</div>

<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "12px" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: "12px" }}>
<StatCard
label="Heap Used (MB)"
value={memory?.usedJSHeapSize ? (memory.usedJSHeapSize / (1024 * 1024)).toFixed(2) : "n/a"}
Expand All @@ -75,6 +126,68 @@ export default function SystemHealth() {
label="Load Event (ms)"
value={snapshot?.navigation?.loadEventMs || "n/a"}
/>
<StatCard
label="Avg Latency"
value={averageLatency != null ? `${averageLatency} ms` : "n/a"}
accent={averageLatency != null && averageLatency > 1200 ? "var(--amber)" : "var(--green)"}
/>
</div>

<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: "12px" }}>
<StatCard label="Networks Probed" value={networkHealth.length} />
<StatCard label="Open Breakers" value={openBreakers} accent={openBreakers ? "var(--red)" : "var(--cyan)"} />
<StatCard label="Probes Last Updated" value={snapshot?.timestamp || "n/a"} />
<StatCard
label="Latency History"
value={latencyHistory.length ? `${latencyHistory.length} points` : "pending"}
/>
</div>

{networkHealth.length > 0 && (
<div
style={{
display: "grid",
gap: "12px",
}}
>
<div style={{ fontFamily: "var(--font-display)", fontSize: "13px" }}>Network Probes</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", gap: "12px" }}>
{networkHealth.map((network) => (
<div
key={network.network}
style={{
border: "1px solid var(--border)",
borderRadius: "var(--radius-lg)",
background: "var(--bg-card)",
padding: "14px",
}}
>
<div style={{ fontSize: "13px", fontWeight: 700, marginBottom: "10px" }}>
{network.name}
</div>
<div style={{ display: "grid", gap: "10px" }}>
<ServiceStatus label="Horizon" probe={network.horizon} />
<ServiceStatus label="Soroban" probe={network.soroban} />
</div>
</div>
))}
</div>
</div>
)}

<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-lg)",
padding: "14px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
>
<div style={{ fontFamily: "var(--font-display)", fontSize: "13px" }}>Latency Trend</div>
<LatencyTrendChart data={latencyHistory} />
</div>

<div
Expand Down
31 changes: 27 additions & 4 deletions src/hooks/useMonitoring.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import {
collectHealthSnapshot,
collectSystemHealthSnapshot,
computeHealthScore,
watchErrors,
} from "../utils/monitoring";
import { alertCenter, evaluateAlertRules } from "../lib/alerts";

export function useMonitoring(pollIntervalMs = 15000) {
const [snapshot, setSnapshot] = useState(() => collectHealthSnapshot());
const [snapshot, setSnapshot] = useState(() => ({
...collectHealthSnapshot(),
networkHealth: [],
latencyHistory: [],
}));
const [errors, setErrors] = useState([]);
const [alerts, setAlerts] = useState([]);

Expand All @@ -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();
Expand Down
116 changes: 116 additions & 0 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceProbeResult> {
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<NetworkProbeResult[]> {
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(
Expand Down
Loading
Loading