|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useEffect, useState } from "react"; |
4 | | -import { getQueueCount } from "@/lib/offlineQueue"; |
| 3 | +import { useEffect, useState, useRef, useCallback } from "react"; |
5 | 4 |
|
6 | 5 | 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; |
9 | 7 |
|
10 | | -type Status = "online" | "offline" | "checking"; |
| 8 | +type RpcStatus = "healthy" | "degraded" | "offline" | "checking"; |
11 | 9 |
|
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"; |
42 | 27 | } |
| 28 | +} |
43 | 29 |
|
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); |
48 | 43 | }, []); |
49 | 44 |
|
50 | 45 | 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]); |
67 | 65 |
|
68 | 66 | 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"; |
74 | 71 |
|
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…"; |
76 | 81 |
|
77 | 82 | 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> |
88 | 99 | )} |
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} |
92 | 109 | </span> |
93 | | - )} |
94 | | - </div> |
| 110 | + </div> |
| 111 | + </> |
95 | 112 | ); |
96 | 113 | } |
0 commit comments