Skip to content

Commit 138eb8e

Browse files
ehsan6shaclaude
andcommitted
apps/box: Diagnostics — add Fula network reachability card
The phone-connectivity card only proved "your phone has internet"; it said nothing about whether the phone could reach the Fula relays the box actually depends on. A user can have green ✓ internet and red ✗ blox connectivity with no explanation in between. Add a third probe card showing: 1. Fula discovery service reachability. HEAD to https://discovery.fula.network/relays with the same x-fula-client WAF gate header helper.ts uses. Mirrors the device-side check readiness-check.py runs (check_discovery_https_reachable). 2. Per-relay reachability. For each relay returned by discovery (or from the AsyncStorage cache if discovery is unreachable, or from the hardcoded FXRelay multiaddr as a last resort — same 3-tier fallback helper.ts:findBox uses for actual connections), probe https://<dnsName>/ with a 5s timeout. Any HTTP response counts as reachable; only network errors / DNS fail / timeout count as failed. Important narrowness, documented in code + user-facing copy: this probe checks HTTPS on :443, NOT libp2p on :4001. Fula relays are Cloudflare-fronted so :443 and :4001 share DNS + network path in practice — a green ✓ is a strong proxy for "host reachable," a red ✗ definitively means something is blocking the host. The i18n string for the relay list ("HTTPS reachability on :443 — proxy for libp2p path") names the limitation directly so the user does not anchor on a false-green next time a corporate firewall blocks 4001 specifically. A real TCP/4001 probe would need react-native-tcp-socket as a new native dep — out of scope here, tracked as a follow-up. Probes run in parallel from a single useEffect on mount; the relay rows seed with a 'checking' spinner and update as each probe lands so the user sees progress instead of a long blank while the slowest relay times out. Tests: 30/30 Diagnostics jest tests pass (was 23 before; added 6 new key existence checks for the new i18n strings, plus the existing route + presence tests still pass). Pre-existing ApprovalModal.test.tsx module-resolve failure unrelated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0713f3a commit 138eb8e

3 files changed

Lines changed: 243 additions & 0 deletions

File tree

apps/box/src/i18n/locales/en/translation.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
"phoneInternetChecking": "Checking phone internet…",
99
"phoneInternetOk": "Your phone can reach the internet",
1010
"phoneInternetFailed": "Your phone may not have working internet",
11+
"fulaNetworkTitle": "Fula network reachability",
12+
"discoveryChecking": "Checking Fula discovery service…",
13+
"discoveryOk": "Discovery service is reachable",
14+
"discoveryFailed": "Discovery service is unreachable (your phone can't find a relay)",
15+
"relaysChecking": "Probing relays…",
16+
"relaysUnknown": "No relays are known yet. Try again once the discovery service is reachable.",
17+
"relaysListLabel": "Relays (HTTPS reachability on :443 — proxy for libp2p path):",
1118
"pluginStatusTitle": "Blox AI plugin",
1219
"pluginChecking": "Checking plugin status…",
1320
"pluginInstalled": "Blox AI is installed on your Blox",

apps/box/src/screens/Diagnostics/Diagnostics.screen.tsx

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,26 @@ import {
3535
} from '@functionland/component-library';
3636
import { useTranslation } from 'react-i18next';
3737
import NetInfo from '@react-native-community/netinfo';
38+
import AsyncStorage from '@react-native-async-storage/async-storage';
3839

3940
import { usePluginsStore } from '../../stores/usePluginsStore';
4041
import { Routes } from '../../navigation/navigationConfig';
42+
import * as Constants from '../../utils/constants';
4143

4244
// Generic captive-portal-style "is the phone online" probe. 204 No Content
4345
// is the standard "internet works" signal — chosen over a Fula-specific URL
4446
// because this card is about the PHONE's connectivity, not the device's
4547
// (per Codex review: don't conflate the two).
4648
const PHONE_INTERNET_PROBE_URL = 'https://www.google.com/generate_204';
4749
const PHONE_INTERNET_PROBE_TIMEOUT_MS = 5000;
50+
const DISCOVERY_PROBE_URL = `${Constants.FXDiscoveryURL}/relays`;
51+
const DISCOVERY_PROBE_TIMEOUT_MS = 5000;
52+
const RELAY_PROBE_TIMEOUT_MS = 5000;
4853
const BLOX_AI_PLUGIN_NAME = 'blox-ai';
4954

5055
type ProbeStatus = 'checking' | 'ok' | 'failed';
5156
type PluginPresence = 'checking' | 'installed' | 'notInstalledOrUnavailable';
57+
type RelayInfo = { dnsName: string; status: ProbeStatus };
5258

5359
async function probePhoneInternet(): Promise<ProbeStatus> {
5460
try {
@@ -71,13 +77,127 @@ async function probePhoneInternet(): Promise<ProbeStatus> {
7177
}
7278
}
7379

80+
// Probe the Fula discovery API and return the relay list. Falls back to the
81+
// AsyncStorage cache when the live API is unreachable so we can still show
82+
// per-relay status from the last-known list. Final fallback is the hardcoded
83+
// FXRelay constant (parsed from its multiaddr) so the user always sees at
84+
// least one relay to probe — matches the same resolution order helper.ts
85+
// uses for actual connections.
86+
async function probeDiscoveryAndListRelays(): Promise<{
87+
discovery: ProbeStatus;
88+
dnsNames: string[];
89+
}> {
90+
let discovery: ProbeStatus = 'failed';
91+
let liveDnsNames: string[] | null = null;
92+
try {
93+
const controller = new AbortController();
94+
const timer = setTimeout(() => controller.abort(),
95+
DISCOVERY_PROBE_TIMEOUT_MS);
96+
try {
97+
const r = await fetch(DISCOVERY_PROBE_URL, {
98+
method: 'GET',
99+
signal: controller.signal,
100+
headers: {
101+
accept: 'application/json',
102+
// Same WAF gate header helper.ts uses for /relays.
103+
'x-fula-client': 'app',
104+
},
105+
});
106+
if (r.status >= 200 && r.status < 300) {
107+
discovery = 'ok';
108+
try {
109+
const list = (await r.json()) as Array<{ dnsName?: string }>;
110+
if (Array.isArray(list)) {
111+
liveDnsNames = list
112+
.map(x => x?.dnsName)
113+
.filter((x): x is string => typeof x === 'string' && x.length > 0);
114+
}
115+
} catch {
116+
// Body parse failed but discovery is reachable; we'll
117+
// fall through to cache/hardcoded for the relay list.
118+
}
119+
}
120+
} finally {
121+
clearTimeout(timer);
122+
}
123+
} catch {
124+
discovery = 'failed';
125+
}
126+
127+
if (liveDnsNames && liveDnsNames.length > 0) {
128+
return { discovery, dnsNames: liveDnsNames };
129+
}
130+
131+
// Discovery returned nothing usable (or failed entirely). Try cache.
132+
try {
133+
const raw = await AsyncStorage.getItem(Constants.FXRelayCacheKey);
134+
if (raw) {
135+
const parsed = JSON.parse(raw) as { list?: Array<{ dnsName?: string }> };
136+
const cached = (parsed?.list ?? [])
137+
.map(x => x?.dnsName)
138+
.filter((x): x is string => typeof x === 'string' && x.length > 0);
139+
if (cached.length > 0) {
140+
return { discovery, dnsNames: cached };
141+
}
142+
}
143+
} catch {
144+
// Cache unreadable; fall through to hardcoded.
145+
}
146+
147+
// Last-resort: parse the hardcoded relay multiaddr to get a dnsName so
148+
// the user still sees one relay row to probe.
149+
const m = Constants.FXRelay.match(/^\/dns\/([^/]+)/);
150+
const hardcoded = m ? [m[1]] : [];
151+
return { discovery, dnsNames: hardcoded };
152+
}
153+
154+
// Probe a single relay's hostname for HTTPS reachability on port 443. This
155+
// is NOT the same as libp2p reachability — Fula relays serve libp2p on TCP
156+
// 4001, and React Native's fetch can only do HTTPS. On a network that blocks
157+
// 4001 but allows 443 (some corporate firewalls), this probe will show ✓
158+
// while real connections still fail.
159+
//
160+
// Why we ship it anyway: Fula relays are Cloudflare-fronted, so :443 and
161+
// :4001 share DNS + network path. A green ✓ usually means the relay's host
162+
// is reachable; a red ✗ definitively means something is blocking the host.
163+
// The user-facing i18n copy ("HTTPS reachable" rather than "reachable")
164+
// reflects this narrowness. A real TCP/4001 probe would need
165+
// react-native-tcp-socket (not currently a dep); tracked as a follow-up.
166+
//
167+
// Any HTTP response (2xx/3xx/4xx/5xx) means TCP+TLS succeeded — that's the
168+
// signal we care about. The relay may legitimately return 404 on /, since
169+
// it doesn't serve HTTP routes; that's still "reachable" for our purposes.
170+
async function probeRelay(dnsName: string): Promise<ProbeStatus> {
171+
try {
172+
const controller = new AbortController();
173+
const timer = setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
174+
try {
175+
const r = await fetch(`https://${dnsName}/`, {
176+
method: 'HEAD',
177+
signal: controller.signal,
178+
});
179+
// Any standard HTTP status counts as reachable — including 4xx/5xx
180+
// because those still prove TCP+TLS+HTTP completed. The only
181+
// "unreachable" outcomes are network errors / DNS fail / timeout,
182+
// all of which throw rather than resolve.
183+
return r.status >= 100 && r.status < 600 ? 'ok' : 'failed';
184+
} finally {
185+
clearTimeout(timer);
186+
}
187+
} catch {
188+
return 'failed';
189+
}
190+
}
191+
74192
export const DiagnosticsScreen: React.FC = () => {
75193
const { t } = useTranslation();
76194
const { colors } = useFxTheme();
77195
const { listActivePlugins, activePlugins } = usePluginsStore();
78196

79197
const [netInfoConnected, setNetInfoConnected] = React.useState<boolean | null>(null);
80198
const [phoneInternet, setPhoneInternet] = React.useState<ProbeStatus>('checking');
199+
const [discoveryStatus, setDiscoveryStatus] = React.useState<ProbeStatus>('checking');
200+
const [relays, setRelays] = React.useState<RelayInfo[] | null>(null);
81201
const [pluginPresence, setPluginPresence] = React.useState<PluginPresence>('checking');
82202

83203
// Phone-side probes run once on mount. Re-fetching on every focus would
@@ -92,6 +212,38 @@ export const DiagnosticsScreen: React.FC = () => {
92212
return () => unsub();
93213
}, []);
94214

215+
// Fula network reachability: discovery API + per-relay TCP probes.
216+
// Discovery resolves to a list of relay DNS names; we then probe each
217+
// relay in parallel. State is set as results arrive so the UI shows
218+
// progress without waiting for the slowest relay.
219+
React.useEffect(() => {
220+
let cancelled = false;
221+
(async () => {
222+
const { discovery, dnsNames } = await probeDiscoveryAndListRelays();
223+
if (cancelled) return;
224+
setDiscoveryStatus(discovery);
225+
if (dnsNames.length === 0) {
226+
setRelays([]);
227+
return;
228+
}
229+
// Seed the relay list with 'checking' so the user sees the
230+
// hostnames immediately; per-relay status updates as probes
231+
// resolve.
232+
setRelays(dnsNames.map(dnsName => ({ dnsName, status: 'checking' })));
233+
await Promise.all(dnsNames.map(async (dnsName) => {
234+
const status = await probeRelay(dnsName);
235+
if (cancelled) return;
236+
setRelays(prev => {
237+
if (!prev) return prev;
238+
return prev.map(r =>
239+
r.dnsName === dnsName ? { ...r, status } : r
240+
);
241+
});
242+
}));
243+
})();
244+
return () => { cancelled = true; };
245+
}, []);
246+
95247
// Plugin presence — tolerant of pre-plugin firmware per Codex review:
96248
// missing data ≠ "not installed", so the UI copy avoids overclaiming.
97249
//
@@ -176,6 +328,83 @@ export const DiagnosticsScreen: React.FC = () => {
176328

177329
<FxSpacer height={12} />
178330

331+
{/* ───────── Fula network reachability ───────── */}
332+
<FxCard>
333+
<FxCard.Title>
334+
{t('diagnostics.fulaNetworkTitle')}
335+
</FxCard.Title>
336+
<FxBox paddingVertical="8">
337+
{discoveryStatus === 'checking' ? (
338+
<FxBox flexDirection="row" alignItems="center">
339+
<ActivityIndicator size="small" />
340+
<FxSpacer width={8} />
341+
<FxText>{t('diagnostics.discoveryChecking')}</FxText>
342+
</FxBox>
343+
) : discoveryStatus === 'ok' ? (
344+
<FxText color="successBase">
345+
{t('diagnostics.discoveryOk')}
346+
</FxText>
347+
) : (
348+
<FxText color="errorBase">
349+
{t('diagnostics.discoveryFailed')}
350+
</FxText>
351+
)}
352+
<FxSpacer height={8} />
353+
{relays === null ? (
354+
<FxBox flexDirection="row" alignItems="center">
355+
<ActivityIndicator size="small" />
356+
<FxSpacer width={8} />
357+
<FxText>{t('diagnostics.relaysChecking')}</FxText>
358+
</FxBox>
359+
) : relays.length === 0 ? (
360+
<FxText variant="bodySmallRegular">
361+
{t('diagnostics.relaysUnknown')}
362+
</FxText>
363+
) : (
364+
<>
365+
<FxText variant="bodySmallRegular">
366+
{t('diagnostics.relaysListLabel')}
367+
</FxText>
368+
<FxSpacer height={4} />
369+
{relays.map((r) => (
370+
<FxBox
371+
key={r.dnsName}
372+
flexDirection="row"
373+
alignItems="center"
374+
paddingVertical="4"
375+
>
376+
{r.status === 'checking' ? (
377+
<>
378+
<ActivityIndicator size="small" />
379+
<FxSpacer width={8} />
380+
<FxText variant="bodySmallRegular">
381+
{r.dnsName}
382+
</FxText>
383+
</>
384+
) : r.status === 'ok' ? (
385+
<FxText
386+
variant="bodySmallRegular"
387+
color="successBase"
388+
>
389+
{r.dnsName}
390+
</FxText>
391+
) : (
392+
<FxText
393+
variant="bodySmallRegular"
394+
color="errorBase"
395+
>
396+
{r.dnsName}
397+
</FxText>
398+
)}
399+
</FxBox>
400+
))}
401+
</>
402+
)}
403+
</FxBox>
404+
</FxCard>
405+
406+
<FxSpacer height={12} />
407+
179408
{/* ───────── Plugin presence ───────── */}
180409
<FxCard>
181410
<FxCard.Title>{t('diagnostics.pluginStatusTitle')}</FxCard.Title>

apps/box/src/screens/Diagnostics/__tests__/Diagnostics.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ describe('i18n strings present', () => {
9696
'diagnostics.phoneInternetChecking',
9797
'diagnostics.phoneInternetOk',
9898
'diagnostics.phoneInternetFailed',
99+
'diagnostics.fulaNetworkTitle',
100+
'diagnostics.discoveryChecking',
101+
'diagnostics.discoveryOk',
102+
'diagnostics.discoveryFailed',
103+
'diagnostics.relaysChecking',
104+
'diagnostics.relaysUnknown',
105+
'diagnostics.relaysListLabel',
99106
'diagnostics.pluginStatusTitle',
100107
'diagnostics.pluginChecking',
101108
'diagnostics.pluginInstalled',

0 commit comments

Comments
 (0)