Skip to content

Commit 5e694aa

Browse files
authored
feat(#317): add Soroban RPC health indicator with tooltip and offline banner (#340)
- Replaces getLatestLedger with getHealth call per Soroban spec - Polls every 60s, pauses when tab hidden via Page Visibility API - Green/yellow/red dot in Navbar with hover tooltip showing status + seconds since last check - Fixed-position dismissible red banner when RPC is offline - Silent failure; never blocks the UI
1 parent 96ca89f commit 5e694aa

2 files changed

Lines changed: 96 additions & 77 deletions

File tree

src/components/Navbar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ThemeToggle from "@/components/ThemeToggle";
77
import SimulationModeToggle from "@/components/SimulationModeToggle";
88
import NotificationCenter from "@/components/NotificationCenter";
99
import HeaderShortcutsButton from "@/components/HeaderShortcutsButton";
10+
import NetworkStatus from "@/components/NetworkStatus";
1011

1112
const NAV_LINKS = [
1213
{ href: "/dashboard", label: "Dashboard" },
@@ -63,7 +64,8 @@ export default function Navbar() {
6364

6465
{/* Right-side actions */}
6566
<div className="ml-auto flex items-center gap-1">
66-
<div className="hidden sm:flex items-center gap-1">
67+
<div className="hidden sm:flex items-center gap-2">
68+
<NetworkStatus />
6769
<SimulationModeToggle />
6870
<NotificationCenter />
6971
<HeaderShortcutsButton />

src/components/NetworkStatus.tsx

Lines changed: 93 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,113 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
4-
import { getQueueCount } from "@/lib/offlineQueue";
3+
import { useEffect, useState, useRef, useCallback } from "react";
54

65
const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? "";
7-
const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? "testnet";
8-
const POLL_MS = 30_000;
6+
const POLL_MS = 60_000;
97

10-
type Status = "online" | "offline" | "checking";
8+
type RpcStatus = "healthy" | "degraded" | "offline" | "checking";
119

12-
export default function NetworkStatus() {
13-
const [status, setStatus] = useState<Status>("checking");
14-
const [lastPing, setLastPing] = useState<Date | null>(null);
15-
const [queueCount, setQueueCount] = useState(0);
16-
17-
async function ping() {
18-
if (!RPC_URL) {
19-
setStatus("offline");
20-
return;
21-
}
22-
try {
23-
const res = await fetch(RPC_URL, {
24-
method: "POST",
25-
headers: { "Content-Type": "application/json" },
26-
body: JSON.stringify({
27-
jsonrpc: "2.0",
28-
id: 1,
29-
method: "getLatestLedger",
30-
params: {},
31-
}),
32-
});
33-
if (res.ok) {
34-
setStatus("online");
35-
setLastPing(new Date());
36-
} else {
37-
setStatus("offline");
38-
}
39-
} catch {
40-
setStatus("offline");
41-
}
10+
async function checkHealth(): Promise<RpcStatus> {
11+
if (!RPC_URL) return "offline";
12+
try {
13+
const res = await fetch(RPC_URL, {
14+
method: "POST",
15+
headers: { "Content-Type": "application/json" },
16+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth", params: {} }),
17+
signal: AbortSignal.timeout(8000),
18+
});
19+
if (!res.ok) return "offline";
20+
const data = await res.json();
21+
const s: string = data?.result?.status ?? "";
22+
if (s === "healthy") return "healthy";
23+
if (s === "degraded") return "degraded";
24+
return "degraded";
25+
} catch {
26+
return "offline";
4227
}
28+
}
4329

44-
useEffect(() => {
45-
ping();
46-
const id = setInterval(ping, POLL_MS);
47-
return () => clearInterval(id);
30+
export default function NetworkStatus() {
31+
const [status, setStatus] = useState<RpcStatus>("checking");
32+
const [checkedAt, setCheckedAt] = useState<Date | null>(null);
33+
const [dismissed, setDismissed] = useState(false);
34+
const [secondsAgo, setSecondsAgo] = useState(0);
35+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
36+
37+
const poll = useCallback(async () => {
38+
const s = await checkHealth();
39+
setStatus(s);
40+
setCheckedAt(new Date());
41+
setSecondsAgo(0);
42+
if (s !== "offline") setDismissed(false);
4843
}, []);
4944

5045
useEffect(() => {
51-
let cancelled = false;
52-
async function pollQueue() {
53-
try {
54-
const count = await getQueueCount();
55-
if (!cancelled) setQueueCount(count);
56-
} catch {
57-
// IndexedDB unavailable
58-
}
59-
}
60-
pollQueue();
61-
const id = setInterval(pollQueue, POLL_MS);
62-
return () => {
63-
cancelled = true;
64-
clearInterval(id);
65-
};
66-
}, []);
46+
poll();
47+
48+
const start = () => { timerRef.current = setInterval(poll, POLL_MS); };
49+
const stop = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } };
50+
51+
const onVisibility = () => { document.hidden ? stop() : (poll(), start()); };
52+
53+
start();
54+
document.addEventListener("visibilitychange", onVisibility);
55+
return () => { stop(); document.removeEventListener("visibilitychange", onVisibility); };
56+
}, [poll]);
57+
58+
useEffect(() => {
59+
if (!checkedAt) return;
60+
const id = setInterval(() => {
61+
setSecondsAgo(Math.floor((Date.now() - checkedAt.getTime()) / 1000));
62+
}, 5000);
63+
return () => clearInterval(id);
64+
}, [checkedAt]);
6765

6866
const dotColor =
69-
status === "online"
70-
? "bg-green-400"
71-
: status === "offline"
72-
? "bg-red-500"
73-
: "bg-yellow-400 animate-pulse";
67+
status === "healthy" ? "bg-green-400"
68+
: status === "degraded" ? "bg-yellow-400"
69+
: status === "offline" ? "bg-red-500"
70+
: "bg-gray-400 animate-pulse";
7471

75-
const label = NETWORK.charAt(0).toUpperCase() + NETWORK.slice(1);
72+
const label =
73+
status === "healthy" ? "Healthy"
74+
: status === "degraded" ? "Degraded"
75+
: status === "offline" ? "Offline"
76+
: "Checking…";
77+
78+
const tooltip = checkedAt
79+
? `Soroban RPC: ${label} — last checked ${secondsAgo === 0 ? "just now" : `${secondsAgo}s ago`}`
80+
: "Soroban RPC: checking…";
7681

7782
return (
78-
<div className="flex items-center gap-2 text-xs text-gray-500">
79-
<span
80-
className={`inline-block w-2 h-2 rounded-full ${dotColor}`}
81-
aria-label={`RPC ${status}`}
82-
/>
83-
<span>{label}</span>
84-
{lastPing && (
85-
<span title={lastPing.toLocaleTimeString()}>
86-
· {lastPing.toLocaleTimeString()}
87-
</span>
83+
<>
84+
{status === "offline" && !dismissed && (
85+
<div
86+
role="alert"
87+
className="fixed top-0 left-0 right-0 z-[9999] flex items-center justify-between gap-3 bg-red-900/95 border-b border-red-700 px-4 py-2 text-sm text-red-100"
88+
>
89+
<span>Network issues detected — transactions may fail</span>
90+
<button
91+
type="button"
92+
onClick={() => setDismissed(true)}
93+
className="shrink-0 text-red-300 hover:text-white transition-colors"
94+
aria-label="Dismiss network warning"
95+
>
96+
97+
</button>
98+
</div>
8899
)}
89-
{queueCount > 0 && (
90-
<span className="text-yellow-400" data-testid="queue-count">
91-
· {queueCount} payment{queueCount !== 1 ? "s" : ""} queued
100+
101+
<div className="group relative flex items-center gap-1.5 cursor-default select-none">
102+
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`} aria-hidden="true" />
103+
<span className="text-xs text-gray-400 hidden sm:inline">{label}</span>
104+
<span
105+
role="tooltip"
106+
className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 hidden group-hover:block whitespace-nowrap rounded bg-gray-900 border border-gray-700 px-2 py-1 text-xs text-gray-200 shadow-lg"
107+
>
108+
{tooltip}
92109
</span>
93-
)}
94-
</div>
110+
</div>
111+
</>
95112
);
96113
}

0 commit comments

Comments
 (0)