From bbf3b645af174e520b0db647c2434eadda58e075 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Sun, 3 May 2026 21:53:40 +0200 Subject: [PATCH 1/3] feat(upload): add Bee gateway health check before upload Probe GET /health once when the upload step opens, using fetch with a 5s AbortController timeout instead of bee-js. Block upload and show a banner when the node is unreachable or unhealthy; Retry re-runs the probe. Document BeeNodeHealth in the architecture layout. --- docs/architecture.md | 1 + src/app/components/BeeNodeHealth.ts | 191 ++++++++++++++++++ src/app/components/SwapComponent.tsx | 54 ++++- .../components/css/SwapComponent.module.css | 83 ++++++++ 4 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/app/components/BeeNodeHealth.ts diff --git a/docs/architecture.md b/docs/architecture.md index bee285a..068c42b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,7 @@ graph TB src/app/ ├── components/ │ ├── SwapComponent.tsx # Main upload interface +│ ├── BeeNodeHealth.ts # Bee /health probe + hook for upload gating │ ├── FileUploadUtils.ts # Upload logic and utilities │ ├── NFTCollectionProcessor.ts # NFT collection processing │ ├── ArchiveProcessor.ts # ZIP/TAR file handling diff --git a/src/app/components/BeeNodeHealth.ts b/src/app/components/BeeNodeHealth.ts new file mode 100644 index 0000000..7c75a41 --- /dev/null +++ b/src/app/components/BeeNodeHealth.ts @@ -0,0 +1,191 @@ +/** + * Lightweight liveness probe for the Bee gateway shown on the Upload page. + * + * Why this exists: + * Self-custody upload silently dies a hundred different ways when the + * gateway is unhealthy — chunks 5xx, the manifest GET 502s, etc. The + * user sees a frozen progress bar and assumes the app is broken. A + * one-time `/health` check when the upload step opens catches "node is + * down / wrong URL / syncing" up-front: (a) show a clear message, + * (b) disable Upload until it passes. Users can Retry to probe again + * after fixing the gateway. + * + * The probe uses a raw `fetch` (not `bee.getHealth()`) so we can pin a + * tight 5-second timeout via AbortController; bee-js falls back to axios + * which has no default timeout and would happily hang for 60 s+ on a + * stuck gateway, defeating the whole "fast feedback" point. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Result of a single `/health` probe. + * + * - `unknown` — no probe has completed yet (initial) + * - `checking` — a probe is in flight; UI should show a subtle spinner + * - `ok` — node returned `{status:'ok'}` within the timeout + * - `unreachable` — fetch failed (network error, CORS, DNS, connection refused, timeout) + * - `unhealthy` — node responded but with a non-2xx status, or `{status}` ≠ ok + */ +export type BeeHealthStatus = 'unknown' | 'checking' | 'ok' | 'unreachable' | 'unhealthy'; + +export interface BeeHealthState { + status: BeeHealthStatus; + /** Human-readable diagnostic shown in the banner. */ + message?: string; + /** Bee node version, when the probe surfaced one. Useful in the banner. */ + version?: string; + /** Wall-clock timestamp of the most recent completed probe. */ + lastChecked?: number; +} + +/** + * Maximum wall-clock time we'll wait for a single `/health` request before + * giving up. The endpoint is trivially cheap on a healthy node — it's a + * static JSON literal — so anything beyond a few seconds means "the gateway + * is sick, stop pretending". + */ +const PROBE_TIMEOUT_MS = 5_000; + +/** + * Run a single `/health` probe. Resolves with a `BeeHealthState` describing + * the outcome — never throws. + * + * Network errors, CORS rejections, DNS failures and AbortController timeouts + * all collapse into `'unreachable'`; HTTP-level rejections (5xx, 503 from a + * still-syncing node) map to `'unhealthy'` so the banner can distinguish + * "your URL is wrong" from "the node is up but unhappy". + */ +export async function probeBeeNodeHealth(beeApiUrl: string): Promise { + if (!beeApiUrl) { + return { + status: 'unreachable', + message: 'No Bee gateway URL is configured.', + lastChecked: Date.now(), + }; + } + + const url = `${beeApiUrl.replace(/\/+$/, '')}/health`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + + try { + const res = await fetch(url, { + method: 'GET', + signal: controller.signal, + // `cache: 'no-store'` so transient outages aren't masked by a stale + // SW or browser-cache hit. Health is by definition not cacheable. + cache: 'no-store', + }); + + if (!res.ok) { + return { + status: 'unhealthy', + message: `Bee gateway responded with HTTP ${res.status} ${res.statusText || ''}`.trim(), + lastChecked: Date.now(), + }; + } + + // The standard Bee /health response is `{status:'ok', version, apiVersion}`. + // We tolerate gateways that don't return JSON (e.g. plain "OK") by + // treating any 2xx as healthy in that fallback case. + let parsed: { status?: string; version?: string } | null = null; + try { + parsed = (await res.json()) as { status?: string; version?: string }; + } catch { + return { status: 'ok', lastChecked: Date.now() }; + } + + const reportedStatus = (parsed?.status ?? '').toLowerCase(); + if (reportedStatus && reportedStatus !== 'ok') { + return { + status: 'unhealthy', + message: `Bee node reports status: "${parsed!.status}"`, + version: parsed?.version, + lastChecked: Date.now(), + }; + } + + return { + status: 'ok', + version: parsed?.version, + lastChecked: Date.now(), + }; + } catch (err) { + const aborted = (err as Error)?.name === 'AbortError'; + return { + status: 'unreachable', + message: aborted + ? `No response from the Bee gateway within ${PROBE_TIMEOUT_MS / 1000}s.` + : `Cannot reach the Bee gateway: ${(err as Error)?.message ?? 'unknown error'}`, + lastChecked: Date.now(), + }; + } finally { + clearTimeout(timeoutId); + } +} + +export interface UseBeeNodeHealthResult { + state: BeeHealthState; + /** + * True while a probe is in flight. Tracked separately from + * `state.status === 'checking'` so we don't have to clobber a known + * `'unhealthy' | 'unreachable'` state during a re-probe — the banner + * stays visible while the spinner on the Retry button spins. + */ + isProbing: boolean; + /** Force an immediate re-probe. Safe to call from a button onClick. */ + refresh: () => void; +} + +/** + * React hook: runs a single `/health` probe whenever `enabled` becomes true + * or `beeApiUrl` changes while enabled. No background polling — use + * {@link UseBeeNodeHealthResult.refresh} (e.g. a Retry button) to check again. + * Cancels stale in-flight probes on unmount / dependency change. + * + * @param beeApiUrl Bee gateway base URL (no trailing `/health`). + * @param enabled When false, no request runs; state keeps the last value. + * Pass `false` while the upload UI is hidden. + */ +export function useBeeNodeHealth( + beeApiUrl: string, + enabled: boolean = true +): UseBeeNodeHealthResult { + const [state, setState] = useState({ status: 'unknown' }); + const [isProbing, setIsProbing] = useState(false); + // Increments every time we kick off a new probe; the in-flight probe + // checks this on resolution and bails if a newer probe has been started + // (or if the component unmounted). Cheap stand-in for full AbortController + // wiring across the hook. + const probeIdRef = useRef(0); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const probe = useCallback(async () => { + if (!beeApiUrl) return; + const myId = ++probeIdRef.current; + setIsProbing(true); + setState(prev => ({ + ...prev, + status: prev.status === 'unknown' ? 'checking' : prev.status, + })); + const result = await probeBeeNodeHealth(beeApiUrl); + if (!mountedRef.current || probeIdRef.current !== myId) return; + setState(result); + setIsProbing(false); + }, [beeApiUrl]); + + useEffect(() => { + if (!enabled) return; + void probe(); + }, [probe, enabled]); + + return { state, isProbing, refresh: probe }; +} diff --git a/src/app/components/SwapComponent.tsx b/src/app/components/SwapComponent.tsx index 8cc4550..968c813 100644 --- a/src/app/components/SwapComponent.tsx +++ b/src/app/components/SwapComponent.tsx @@ -79,6 +79,7 @@ import { handleFolderSelection } from './FolderUploadUtils'; import { processNFTCollection, NFTCollectionResult } from './NFTCollectionProcessor'; import { generateAndUpdateNonce, fetchNodeWalletAddress } from './utils'; import { useTokenManagement } from './TokenUtils'; +import { useBeeNodeHealth } from './BeeNodeHealth'; // Update the StampInfo interface to include the additional properties interface StampInfo { @@ -203,6 +204,15 @@ const SwapComponent: React.FC = () => { const [beeApiUrl, setBeeApiUrl] = useState(DEFAULT_BEE_API_URL); + const { + state: beeNodeHealth, + isProbing: isBeeNodeHealthProbing, + refresh: refreshBeeNodeHealth, + } = useBeeNodeHealth(beeApiUrl, uploadStep === 'ready'); + + const beeNodeBlocksUpload = + beeNodeHealth.status === 'unreachable' || beeNodeHealth.status === 'unhealthy'; + const [swarmConfig, setSwarmConfig] = useState(DEFAULT_SWARM_CONFIG); const [isCustomNode, setIsCustomNode] = useState(false); @@ -2117,6 +2127,47 @@ const SwapComponent: React.FC = () => { }...${postageBatchId.slice(-4)}` : 'Upload File'} + {beeNodeBlocksUpload && ( +
+
+ Bee gateway not ready +

+ {beeNodeHealth.message ?? + 'Uploads are blocked until the Bee node responds healthy at /health.'} +

+ {beeNodeHealth.version != null && beeNodeHealth.status === 'unhealthy' && ( +

+ Reported version: {beeNodeHealth.version} +

+ )} +

+ Reloading this page runs the same check. +

+
+ +
+ )}
Warning! Uploaded data cannot be deleted - it will be removed once the stamp has expired. Uploaded data exists publicly in the network - anyone who knows @@ -2337,7 +2388,8 @@ const SwapComponent: React.FC = () => { disabled={ (isMultipleFiles ? selectedFiles.length === 0 : !selectedFile) || uploadStep === 'uploading' || - exceedsMaximumUploadSize() + exceedsMaximumUploadSize() || + beeNodeBlocksUpload } className={styles.uploadButton} > diff --git a/src/app/components/css/SwapComponent.module.css b/src/app/components/css/SwapComponent.module.css index 0c5f51e..05f0754 100644 --- a/src/app/components/css/SwapComponent.module.css +++ b/src/app/components/css/SwapComponent.module.css @@ -300,6 +300,89 @@ color: #ffffff; } +.healthBanner { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 8px; + text-align: left; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.healthBannerError { + background-color: rgba(229, 83, 75, 0.12); + border: 1px solid rgba(229, 83, 75, 0.45); + color: #ffb3ad; +} + +.healthBannerWarn { + background-color: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.35); + color: #ffe082; +} + +.healthBannerBody { + flex: 1; + min-width: 0; + font-size: 14px; +} + +.healthBannerBody strong { + display: block; + color: #ffffff; + margin-bottom: 6px; + font-size: 15px; +} + +.healthBannerBody p { + margin: 0 0 6px 0; + line-height: 1.45; +} + +.healthBannerBody p:last-child { + margin-bottom: 0; +} + +.healthBannerMeta { + font-size: 12px; + color: #8b949e; +} + +.healthBannerHint { + margin-top: 10px; + margin-bottom: 0; + font-size: 13px; + line-height: 1.45; + color: rgba(255, 255, 255, 0.78); +} + +.healthBannerRetry { + padding: 8px 14px; + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: #ffffff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.healthBannerRetry:hover:not(:disabled) { + background-color: rgba(255, 255, 255, 0.14); +} + +.healthBannerRetry:disabled { + opacity: 0.7; + cursor: wait; +} + .uploadWarning { margin-bottom: 15px; padding: 8px 12px; From 9f97696b5dbda2e714a5d8eda3f5e76a9671edd9 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Sun, 3 May 2026 23:04:44 +0200 Subject: [PATCH 2/3] fix(stamps): gate stamp API on Bee health; simplify gateway banner Run /health before Your Stamps hits Bee /stamps so a stopped node shows a clear banner instead of fetch errors. Align upload and stamps copy to two-line Settings guidance; orange Retry without grey border; drop stamp header divider under the title. --- src/app/components/BeeNodeHealth.ts | 10 +- src/app/components/StampListSection.tsx | 88 +++++++++++++++-- src/app/components/SwapComponent.tsx | 15 +-- .../css/StampListSection.module.css | 96 ++++++++++++++++++- .../components/css/SwapComponent.module.css | 26 ++--- 5 files changed, 191 insertions(+), 44 deletions(-) diff --git a/src/app/components/BeeNodeHealth.ts b/src/app/components/BeeNodeHealth.ts index 7c75a41..d6a55c7 100644 --- a/src/app/components/BeeNodeHealth.ts +++ b/src/app/components/BeeNodeHealth.ts @@ -1,14 +1,14 @@ /** - * Lightweight liveness probe for the Bee gateway shown on the Upload page. + * Lightweight liveness probe for the Bee gateway (Your Stamps + upload step). * * Why this exists: * Self-custody upload silently dies a hundred different ways when the * gateway is unhealthy — chunks 5xx, the manifest GET 502s, etc. The * user sees a frozen progress bar and assumes the app is broken. A - * one-time `/health` check when the upload step opens catches "node is - * down / wrong URL / syncing" up-front: (a) show a clear message, - * (b) disable Upload until it passes. Users can Retry to probe again - * after fixing the gateway. + * one-time `/health` check when Your Stamps loads or the upload step opens + * catches "node is down / wrong URL / syncing" up-front: (a) show a clear message, + * (b) disable Upload until it passes and skip Bee stamp API calls until healthy. + * Users can Retry to probe again after fixing the gateway. * * The probe uses a raw `fetch` (not `bee.getHealth()`) so we can pin a * tight 5-second timeout via AbortController; bee-js falls back to axios diff --git a/src/app/components/StampListSection.tsx b/src/app/components/StampListSection.tsx index 89f4794..a216d5e 100644 --- a/src/app/components/StampListSection.tsx +++ b/src/app/components/StampListSection.tsx @@ -20,6 +20,7 @@ import { formatDateEU, fetchStampInfo, } from './utils'; +import { useBeeNodeHealth } from './BeeNodeHealth'; // Cache for expired stamps to avoid repeated API calls const EXPIRED_STAMPS_CACHE_KEY = 'beeport_expired_stamps'; @@ -88,6 +89,20 @@ const StampListSection: React.FC = ({ const [isLoading, setIsLoading] = useState(true); const [refreshingStamps, setRefreshingStamps] = useState>(new Set()); + const { + state: beeHealth, + isProbing: isBeeHealthProbing, + refresh: refreshBeeHealth, + } = useBeeNodeHealth(beeApiUrl, !!address); + + const beeNodeBlocks = beeHealth.status === 'unreachable' || beeHealth.status === 'unhealthy'; + + const checkingBeeGateway = + !!address && + (beeHealth.status === 'unknown' || + beeHealth.status === 'checking' || + (beeHealth.status === 'ok' && isBeeHealthProbing)); + // Utility functions for cache management (can be called from dev tools) const clearExpiredStampsCache = () => { try { @@ -225,6 +240,28 @@ const StampListSection: React.FC = ({ }; useEffect(() => { + if (!address) { + setIsLoading(false); + return; + } + + if (beeHealth.status === 'unknown' || beeHealth.status === 'checking') { + setIsLoading(true); + return; + } + + if (beeHealth.status === 'unreachable' || beeHealth.status === 'unhealthy') { + setIsLoading(false); + setStamps([]); + return; + } + + // `ok` but a probe is still in flight (e.g. Bee URL just changed) — wait before stamp API calls + if (isBeeHealthProbing) { + setIsLoading(true); + return; + } + const isStampKnownExpired = (batchId: string): boolean => { const cache = getExpiredStampsCache(); const cachedEntry = cache[batchId]; @@ -283,11 +320,7 @@ const StampListSection: React.FC = ({ }; const fetchStamps = async () => { - if (!address) { - setIsLoading(false); - return; - } - + setIsLoading(true); try { // Create a client with the registry ABI const client = createPublicClient({ @@ -410,12 +443,12 @@ const StampListSection: React.FC = ({ } }; - fetchStamps(); - }, [address, beeApiUrl, nodeAddress]); // Re-fetch when address, API URL, or node changes + void fetchStamps(); + }, [address, beeApiUrl, nodeAddress, beeHealth.status, isBeeHealthProbing]); // Function to refresh a specific stamp const refreshSingleStamp = async (stampToRefresh: BatchEvent) => { - if (!address) return; + if (!address || beeNodeBlocks) return; const batchId = stampToRefresh.batchId; setRefreshingStamps(prev => new Set(prev).add(batchId)); @@ -476,10 +509,45 @@ const StampListSection: React.FC = ({

Your Stamps

+ {address && beeNodeBlocks && ( +
+
+ Bee Node gateway not working +

+ Change the Bee API gateway URL in Settings or try again later +

+
+ +
+ )} + {!address ? (
Connect wallet to check stamps
- ) : isLoading ? ( -
Loading stamps...
+ ) : beeNodeBlocks ? null : isLoading ? ( +
+ {checkingBeeGateway ? 'Checking Bee gateway…' : 'Loading stamps...'} +
) : stamps.length === 0 ? (
No stamps found
) : ( diff --git a/src/app/components/SwapComponent.tsx b/src/app/components/SwapComponent.tsx index 968c813..524b267 100644 --- a/src/app/components/SwapComponent.tsx +++ b/src/app/components/SwapComponent.tsx @@ -2137,18 +2137,9 @@ const SwapComponent: React.FC = () => { role="alert" >
- Bee gateway not ready -

- {beeNodeHealth.message ?? - 'Uploads are blocked until the Bee node responds healthy at /health.'} -

- {beeNodeHealth.version != null && beeNodeHealth.status === 'unhealthy' && ( -

- Reported version: {beeNodeHealth.version} -

- )} -

- Reloading this page runs the same check. + Bee Node gateway not working +

+ Change the Bee API gateway URL in Settings