From a6914d72ec53f2dd144d8919aaa2ede42b84f280 Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Fri, 9 Oct 2026 21:27:23 +0530 Subject: [PATCH 1/7] fix(gateways): keep the gateway page current and its view across reloads The gateway page often showed stale data until a manual refresh, and lost the selected time window on every reload. - Refetch when Nginx answers from an expired cache entry (X-Cache-Status STALE/UPDATING), with a growing delay until fresh data arrives; refresh a minute after each load and on returning to the tab, never interrupting a load in flight; keep statuses and "seen" times ticking. - Keep the window, status filter, sort and direction in the URL; remember the last window picked for visits without one. - Render as soon as the recorded gateways arrive; run the live registry lookup in the background with a timeout and skip it for offline federations (it blocked the page for ~60 s). - Tie each response and error to its window: a switch shows the previous window's numbers dimmed, a failed refresh keeps the last data with a Try again that shows progress, and a failed first load keeps the controls. - Reload once per build when a lazy chunk is gone after a deploy, and add error boundaries so a crash can no longer blank the whole app. - Remount federation pages per id so another federation's data never shows. - Smaller UX fixes: no banners in normal states, stacked cards on phones, one accessible element per availability strip, aria-pressed controls, endpoints as copyable text, chart via echarts/core with dark-mode colours and time-zone-stable day labels, guarded localStorage. Co-Authored-By: Claude Opus 5.5 --- fmo_frontend_react/src/App.tsx | 34 +- .../src/components/ErrorBoundary.tsx | 42 + .../src/components/GatewayWarningPage.tsx | 13 +- .../src/components/UptimeTrendChart.tsx | 13 + fmo_frontend_react/src/hooks/useTheme.tsx | 9 +- fmo_frontend_react/src/main.tsx | 15 + .../src/pages/FederationGateways.tsx | 800 ++++++++++++------ fmo_frontend_react/src/services/api.ts | 48 +- fmo_frontend_react/src/utils/storage.ts | 22 + 9 files changed, 689 insertions(+), 307 deletions(-) create mode 100644 fmo_frontend_react/src/components/ErrorBoundary.tsx create mode 100644 fmo_frontend_react/src/components/UptimeTrendChart.tsx create mode 100644 fmo_frontend_react/src/utils/storage.ts diff --git a/fmo_frontend_react/src/App.tsx b/fmo_frontend_react/src/App.tsx index 2ebd4a9..c68af24 100644 --- a/fmo_frontend_react/src/App.tsx +++ b/fmo_frontend_react/src/App.tsx @@ -1,11 +1,35 @@ -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import type { ComponentType } from 'react'; +import { BrowserRouter as Router, Routes, Route, useLocation, useParams } from 'react-router-dom'; import { NavBar } from './components/NavBar'; +import { ErrorBoundary, ReloadMessage } from './components/ErrorBoundary'; import { Home } from './pages/Home'; import { Nostr } from './pages/Nostr'; import { FederationDetail } from './pages/FederationDetail'; import { FederationGateways } from './pages/FederationGateways'; import { useTheme } from './hooks/useTheme'; +// The router reuses a page when only :id changes; remount it so it never shows +// the previous federation's data under the new URL +function PerFederation({ page: Page }: { page: ComponentType }) { + const { id } = useParams<{ id: string }>(); + return ; +} + +function AppRoutes() { + const { pathname } = useLocation(); + return ( + }> + + } /> + } /> + } /> + } /> + Page not found} /> + + + ); +} + function App() { const { theme, toggleTheme } = useTheme(); @@ -13,13 +37,7 @@ function App() {
- - } /> - } /> - } /> - } /> - Page not found} /> - +
); diff --git a/fmo_frontend_react/src/components/ErrorBoundary.tsx b/fmo_frontend_react/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..f4e938c --- /dev/null +++ b/fmo_frontend_react/src/components/ErrorBoundary.tsx @@ -0,0 +1,42 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface ErrorBoundaryProps { + fallback: ReactNode; + children: ReactNode; +} + +interface ErrorBoundaryState { + failed: boolean; +} + +// Without a boundary, React unmounts the whole app on a render error, leaving a blank page +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('Render error:', error, info.componentStack); + } + + render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + +export function ReloadMessage({ message }: { message: string }) { + return ( +
+

{message}

+ +
+ ); +} diff --git a/fmo_frontend_react/src/components/GatewayWarningPage.tsx b/fmo_frontend_react/src/components/GatewayWarningPage.tsx index 54115ea..1ee4bf7 100644 --- a/fmo_frontend_react/src/components/GatewayWarningPage.tsx +++ b/fmo_frontend_react/src/components/GatewayWarningPage.tsx @@ -10,6 +10,7 @@ export interface GatewayWarningState { interface GatewayWarningPageProps { warning: GatewayWarningState; className?: string; + action?: { label: string; onClick: () => void; busy?: boolean }; } function levelClasses(level: GatewayWarningLevel): string { @@ -23,7 +24,7 @@ function levelClasses(level: GatewayWarningLevel): string { } } -export function GatewayWarningPage({ warning, className = '' }: GatewayWarningPageProps) { +export function GatewayWarningPage({ warning, className = '', action }: GatewayWarningPageProps) { return (
)} + {action && ( + + )}
); } diff --git a/fmo_frontend_react/src/components/UptimeTrendChart.tsx b/fmo_frontend_react/src/components/UptimeTrendChart.tsx new file mode 100644 index 0000000..c6c6581 --- /dev/null +++ b/fmo_frontend_react/src/components/UptimeTrendChart.tsx @@ -0,0 +1,13 @@ +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import { LineChart } from 'echarts/charts'; +import { GridComponent, TooltipComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +import type { EChartsOption } from 'echarts'; + +// Register only what the chart uses instead of loading all of echarts +echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]); + +export function UptimeTrendChart({ option }: { option: EChartsOption }) { + return ; +} diff --git a/fmo_frontend_react/src/hooks/useTheme.tsx b/fmo_frontend_react/src/hooks/useTheme.tsx index 4f62528..20ecb2c 100644 --- a/fmo_frontend_react/src/hooks/useTheme.tsx +++ b/fmo_frontend_react/src/hooks/useTheme.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { readStorage, writeStorage } from '../utils/storage'; export type Theme = 'light' | 'dark' | 'auto'; @@ -15,7 +16,7 @@ const getStoredTheme = (): Theme | null => { if (typeof window === 'undefined') { return null; } - const stored = window.localStorage.getItem('theme'); + const stored = readStorage('theme'); if (stored === 'light' || stored === 'dark' || stored === 'auto') { return stored; } @@ -100,11 +101,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // Save to localStorage, or clear it for auto mode if (typeof window !== 'undefined') { - if (newTheme === 'auto') { - window.localStorage.removeItem('theme'); - } else { - window.localStorage.setItem('theme', newTheme); - } + writeStorage('theme', newTheme === 'auto' ? null : newTheme); } return newTheme; }); diff --git a/fmo_frontend_react/src/main.tsx b/fmo_frontend_react/src/main.tsx index d024c71..f2fd2d2 100644 --- a/fmo_frontend_react/src/main.tsx +++ b/fmo_frontend_react/src/main.tsx @@ -4,6 +4,21 @@ import './index.css' import App from './App.tsx' import { ThemeProvider } from './hooks/useTheme' +// A tab opened before a deploy still points at the previous build's chunks, which the +// deploy removed. Reload once to pick up the new build instead of failing the page. +// The guard is per build, so a chunk that keeps failing can never cause a reload loop. +const CHUNK_RELOAD_KEY = 'chunkReloadBuild' +window.addEventListener('vite:preloadError', (event) => { + try { + if (sessionStorage.getItem(CHUNK_RELOAD_KEY) === import.meta.url) return // let the error boundary show + sessionStorage.setItem(CHUNK_RELOAD_KEY, import.meta.url) + event.preventDefault() + window.location.reload() + } catch { + // Without storage there is no loop guard, so leave it to the error boundary + } +}) + createRoot(document.getElementById('root')!).render( diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx index 3f380fe..8199cd1 100644 --- a/fmo_frontend_react/src/pages/FederationGateways.tsx +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -1,22 +1,46 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { Link, useParams, useSearchParams } from 'react-router-dom'; import { api } from '../services/api'; import type { FederationSummary, GatewayInfo, GatewayUptimeTrendPoint, - GatewayWindow, } from '../types/api'; import { GatewayWarningPage, type GatewayWarningState } from '../components/GatewayWarningPage'; +import { ErrorBoundary, ReloadMessage } from '../components/ErrorBoundary'; +import { readStorage, writeStorage } from '../utils/storage'; +import { useTheme } from '../hooks/useTheme'; +import type { EChartsOption } from 'echarts'; type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown'; type UptimeStripStatus = 'online' | 'degraded' | 'offline' | 'unknown'; -type GatewayFilter = 'all' | GatewayStatus; -type GatewaySort = 'freshness' | 'status' | 'uptime' | 'activity'; -type SortDirection = 'asc' | 'desc'; -const UptimeTrendChart = lazy(() => import('echarts-for-react')); +// The backend also accepts 1h, which is too short for its 5-minute polls +const GATEWAY_WINDOWS = ['24h', '7d', '30d', '90d'] as const; +type SelectableWindow = (typeof GATEWAY_WINDOWS)[number]; +const GATEWAY_FILTERS = ['all', 'online', 'degraded', 'offline', 'unknown'] as const; +const GATEWAY_SORTS = ['freshness', 'status', 'uptime', 'activity'] as const; +type GatewaySort = (typeof GATEWAY_SORTS)[number]; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; +type SortDirection = (typeof SORT_DIRECTIONS)[number]; + +const UptimeTrendChart = lazy(() => import('../components/UptimeTrendChart').then((module) => ({ default: module.UptimeTrendChart }))); const INITIAL_RENDER_COUNT = 50; +const DEFAULT_WINDOW: SelectableWindow = '7d'; +const WINDOW_STORAGE_KEY = 'gatewayWindow'; +const WINDOW_MINUTES: Record = { + '24h': 24 * 60, + '7d': 7 * 24 * 60, + '30d': 30 * 24 * 60, + '90d': 90 * 24 * 60, +}; +// Gateways are polled every 5 minutes and Nginx caches answers for 6 +const REFRESH_INTERVAL_MS = 60_000; +const REFRESH_CHECK_MS = 10_000; +const STALE_RETRY_MS = 2_500; // doubled after each stale answer +const MAX_STALE_RETRIES = 4; +const LIVE_LOOKUP_TIMEOUT_MS = 10_000; +const EMPTY_TREND: GatewayUptimeTrendPoint[] = []; const STATUS_RANK: Record = { offline: 0, degraded: 1, @@ -48,9 +72,9 @@ function parseTimestamp(value?: string): Date | null { return Number.isNaN(parsed.getTime()) ? null : parsed; } -function getGatewayStatus(lastSeen: Date | null): GatewayStatus { +function getGatewayStatus(lastSeen: Date | null, now: number): GatewayStatus { if (!lastSeen) return 'unknown'; - const minutes = (Date.now() - lastSeen.getTime()) / (1000 * 60); + const minutes = (now - lastSeen.getTime()) / (1000 * 60); if (minutes <= 10) return 'online'; if (minutes <= 30) return 'degraded'; return 'offline'; @@ -67,10 +91,10 @@ function formatDateTime(date: Date | null): string { }); } -function formatRelative(date: Date | null): string { +function formatRelative(date: Date | null, now: number): string { if (!date) return 'Never seen'; - const diffMs = Date.now() - date.getTime(); + const diffMs = now - date.getTime(); const minutes = Math.floor(diffMs / (1000 * 60)); if (minutes < 1) return 'just now'; if (minutes < 60) return `${minutes}m ago`; @@ -204,16 +228,6 @@ function getUptimeBucketTooltip( )} · Estimated ${status}`; } -function formatEndpointLabel(endpoint: string): string { - try { - const url = new URL(endpoint); - if (url.protocol === 'iroh:') return `iroh://${shortId(url.host)}`; - return `${url.host}${url.pathname === '/' ? '' : url.pathname}`; - } catch { - return shortId(endpoint); - } -} - function mergeGatewayData(observedGateways: GatewayInfo[], liveGateways: GatewayInfo[]): GatewayInfo[] { if (observedGateways.length === 0) return liveGateways; if (liveGateways.length === 0) return observedGateways; @@ -253,6 +267,7 @@ function mergeGatewayData(observedGateways: GatewayInfo[], liveGateways: Gateway interface GatewaySelection { gateways: GatewayInfo[]; warning: GatewayWarningState | null; + retry: boolean; } interface RawAnnouncementDialog { @@ -260,257 +275,413 @@ interface RawAnnouncementDialog { raw: Record; } -function selectGatewayData( - observedGateways: GatewayInfo[], - liveGateways: GatewayInfo[], - observedError: string | null, - liveError: string | null, - hasInvite: boolean, -): GatewaySelection { - if (liveGateways.length > 0) { - if (observedGateways.length === 0) { +type LiveLookup = + | { status: 'pending' } + | { status: 'skipped' } + | { status: 'done'; gateways: GatewayInfo[]; error: string | null }; + +interface LoadError { + window: SelectableWindow; + message: string; + network: boolean; // fetch itself failed, as opposed to an error answer from the API +} + +interface GatewaySources { + observed: GatewayInfo[] | null; // recorded history, null until it has loaded once + observedError: LoadError | null; // the latest request for the selected window failed + live: LiveLookup; // the federation's own registry, which only adds details + federationFailed: boolean; + federationOffline: boolean; + dataWindow: SelectableWindow; +} + +// Banners are only for problems or empty states; normal pages show none +function selectGatewayData({ + observed, + observedError, + live, + federationFailed, + federationOffline, + dataWindow, +}: GatewaySources): GatewaySelection { + const liveGateways = live.status === 'done' ? live.gateways : []; + const liveError = live.status === 'done' ? live.error : null; + + if (observed !== null) { + const gateways = liveGateways.length > 0 ? mergeGatewayData(observed, liveGateways) : observed; + if (observedError) { return { - gateways: liveGateways, + gateways, + retry: true, warning: { - level: 'info', - title: 'Live Gateway Data Only', - message: 'Showing gateways from invite-based live discovery.', - detail: 'Observed gateway history is unavailable on this backend.', + level: 'warning', + title: `Could not load ${observedError.window.toUpperCase()} gateway data`, + message: dataWindow === observedError.window + ? 'Showing the last data that loaded.' + : `Showing ${dataWindow.toUpperCase()} data instead.`, + detail: observedError.message, }, }; } - - return { - gateways: mergeGatewayData(observedGateways, liveGateways), - warning: { - level: 'info', - title: 'Merged Gateway Sources', - message: 'Combined observed history with live invite-based gateway metadata.', - detail: 'Live data provides the latest registry details, while observed data keeps status and activity context.', - }, - }; - } - - if (observedGateways.length > 0) { - if (liveError) { + if (federationFailed) { return { - gateways: observedGateways, + gateways, + retry: true, warning: { level: 'warning', - title: 'Live Lookup Failed', - message: 'Showing observed gateway data from the backend.', - detail: `Live invite lookup error: ${liveError}`, + title: "Could not load the federation's details", + message: 'Its name and the live gateway check are missing until they load.', }, }; } - - if (hasInvite) { + if (observed.length > 0) return { gateways, warning: null, retry: false }; + if (liveGateways.length > 0) { + return { + gateways, + retry: false, + warning: { + level: 'info', + title: 'No recorded history yet', + message: "These gateways come from the federation's live registry. Their status history has not been recorded yet.", + }, + }; + } + if (live.status === 'pending') { + return { + gateways, + retry: false, + warning: { level: 'info', title: 'Checking for gateways', message: "Looking up the federation's gateway registry…" }, + }; + } + if (liveError) { return { - gateways: observedGateways, + gateways, + retry: true, warning: { level: 'warning', - title: 'Live Lookup Returned No Gateways', - message: 'Showing observed gateway data from the backend.', - detail: 'Invite-based lookup returned an empty gateway list.', + title: "Could not check the federation's gateway registry", + message: 'No gateways have been recorded for this federation, and its own registry could not be reached.', + detail: liveError, }, }; } - - return { gateways: observedGateways, warning: null }; - } - - const reason = observedError ?? 'No gateway data available on the configured API backend.'; - if (liveError) { return { - gateways: [], - warning: { - level: 'error', - title: 'Gateway Data Unavailable', - message: 'Could not load gateway data from backend or invite-based live lookup.', - detail: `Backend: ${reason}. Live: ${liveError}`, - }, + gateways, + retry: false, + warning: federationOffline + ? { level: 'info', title: 'Federation offline', message: 'This federation is offline and no gateways have been recorded for it.' } + : { level: 'info', title: 'No gateways', message: 'No gateways have been recorded for this federation.' }, }; } - if (hasInvite) { + // Registry-only rows stand in for the history only once loading it has failed + if (observedError && liveGateways.length > 0) { return { - gateways: [], + gateways: liveGateways, + retry: true, warning: { level: 'warning', - title: 'No Gateways Returned', - message: 'Both backend and invite-based lookup returned no gateway records.', - detail: 'This can happen for new federations or backends with incomplete gateway ingestion.', + title: 'Gateway history unavailable', + message: "Showing gateways from the federation's live registry only.", + detail: observedError?.message, }, }; } return { gateways: [], + retry: true, warning: { - level: 'warning', - title: 'Gateway Data Unavailable', - message: 'The configured backend did not return any gateway data.', - detail: reason, + level: 'error', + title: 'Could not load gateway data', + message: observedError?.network + ? 'Check your connection and try again.' + : 'The observer API returned an error. Try again in a moment.', + detail: observedError?.message, }, }; } +function parseOption(options: readonly T[], value: string | null): T | null { + const lower = value?.toLowerCase(); + return options.find((option) => option === lower) ?? null; +} + +function defaultDirection(sort: GatewaySort): SortDirection { + return sort === 'status' ? 'asc' : 'desc'; +} + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; +} + +const DARK_QUERY = '(prefers-color-scheme: dark)'; +function subscribeToColorScheme(onChange: () => void) { + const media = window.matchMedia(DARK_QUERY); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); +} + +// Re-renders when the system switches between light and dark, for the 'auto' theme +function usePrefersDark(): boolean { + return useSyncExternalStore(subscribeToColorScheme, () => window.matchMedia(DARK_QUERY).matches); +} + export function FederationGateways() { - const { id } = useParams<{ id: string }>(); - const [federation, setFederation] = useState(null); - const [gateways, setGateways] = useState([]); - const [loading, setLoading] = useState(true); - const [windowLoading, setWindowLoading] = useState(false); - const [error, setError] = useState(null); - const [gatewayWarning, setGatewayWarning] = useState(null); - const [timeWindow, setTimeWindow] = useState('7d'); - const [uptimeTrend, setUptimeTrend] = useState([]); - const [uptimeTrendLoading, setUptimeTrendLoading] = useState(true); - const [gatewayFilter, setGatewayFilter] = useState('all'); - const [gatewaySort, setGatewaySort] = useState('freshness'); - const [sortDirection, setSortDirection] = useState('desc'); + // The router remounts this page per federation (App.tsx), so id never changes here + const { id = '' } = useParams<{ id: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + // The view lives in the URL so a refresh, Back or a shared link keeps it. Without a + // window in the URL, e.g. coming from "Gateway Details", the last one picked applies; + // it is read once, so picking a window in another tab does not change this one. + const [storedWindow] = useState(() => parseOption(GATEWAY_WINDOWS, readStorage(WINDOW_STORAGE_KEY))); + const timeWindow = parseOption(GATEWAY_WINDOWS, searchParams.get('window')) ?? storedWindow ?? DEFAULT_WINDOW; + const gatewayFilter = parseOption(GATEWAY_FILTERS, searchParams.get('status')) ?? 'all'; + const gatewaySort = parseOption(GATEWAY_SORTS, searchParams.get('sort')) ?? 'freshness'; + const sortDirection = parseOption(SORT_DIRECTIONS, searchParams.get('dir')) ?? defaultDirection(gatewaySort); + + const [federation, setFederation] = useState(undefined); + const [federationFailed, setFederationFailed] = useState(false); + const [federationKey, setFederationKey] = useState(0); + // Each response is stored with the window it belongs to, so a window switch never + // recomputes the previous window's numbers against the new window's length + const [observed, setObserved] = useState<{ window: SelectableWindow; gateways: GatewayInfo[] } | null>(null); + const [observedError, setObservedError] = useState(null); + const [observedLoading, setObservedLoading] = useState(true); + const [live, setLive] = useState({ status: 'pending' }); + const [liveKey, setLiveKey] = useState(0); + const [trend, setTrend] = useState<{ window: SelectableWindow; points: GatewayUptimeTrendPoint[] } | null>(null); + const [trendFailed, setTrendFailed] = useState(false); + const [trendLoading, setTrendLoading] = useState(true); + const [updatedAt, setUpdatedAt] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const [reloadKey, setReloadKey] = useState(0); // Try again + const [refreshKey, setRefreshKey] = useState(0); // background refresh const [gatewaySearch, setGatewaySearch] = useState(''); const [visibleGatewayCount, setVisibleGatewayCount] = useState(INITIAL_RENDER_COUNT); const [rawAnnouncement, setRawAnnouncement] = useState(null); - const hasLoadedOnce = useRef(false); - const requestSeq = useRef(0); - const federationCache = useRef>(new Map()); - const liveGatewayCache = useRef>(new Map()); + const loadInFlight = useRef(false); + const lastLoadAt = useRef(0); // when the last load finished + const staleAttempts = useRef(0); + const staleScope = useRef(''); + const focusAfterRetry = useRef(false); + const headingRef = useRef(null); + + // One navigation per action; replace keeps Back working. It starts from the address + // bar because the router applies URL updates in a transition, so the hook's copy can + // lag behind a quick second click and drop the first change. + const updateView = useCallback((changes: Partial>) => { + const next = new URLSearchParams(window.location.search); + for (const [key, value] of Object.entries(changes)) { + if (value == null) next.delete(key); + else next.set(key, value); + } + setSearchParams(next, { replace: true }); + }, [setSearchParams]); + + const selectWindow = (nextWindow: SelectableWindow) => { + writeStorage(WINDOW_STORAGE_KEY, nextWindow); + updateView({ window: nextWindow }); + }; useEffect(() => { - if (!id) return; let cancelled = false; - const currentRequest = ++requestSeq.current; + api.getFederations() + .then((federations) => { + if (cancelled) return; + setFederation(federations.find((item) => item.id === id) ?? null); + setFederationFailed(false); + }) + .catch(() => { + // Only the name and the live lookup need it; the recorded data still shows + if (!cancelled) setFederationFailed(true); + }); + return () => { + cancelled = true; + }; + }, [id, federationKey]); + + // Keep retrying the federation list while it is missing + useEffect(() => { + if (!federationFailed) return; + const timer = setTimeout(() => setFederationKey((key) => key + 1), REFRESH_INTERVAL_MS); + return () => clearTimeout(timer); + }, [federationFailed, federationKey]); - if (!hasLoadedOnce.current) { - setLoading(true); - } else { - setWindowLoading(true); + useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + let retryTimer: ReturnType | undefined; + // A new window or a Try again starts over with the stale retries + const scope = `${timeWindow}:${reloadKey}`; + if (staleScope.current !== scope) { + staleScope.current = scope; + staleAttempts.current = 0; } - setError(null); - setGatewayWarning(null); - setUptimeTrend([]); - setUptimeTrendLoading(true); - - (async () => { - try { - void api.getFederationGatewayUptimeTrend(id, timeWindow) - .then((trend) => { - if (!cancelled && currentRequest === requestSeq.current) setUptimeTrend(trend); - }) - .catch(() => { - if (!cancelled && currentRequest === requestSeq.current) setUptimeTrend([]); - }) - .finally(() => { - if (!cancelled && currentRequest === requestSeq.current) setUptimeTrendLoading(false); - }); - let fed: FederationSummary | null | undefined = federationCache.current.get(id); - if (fed === undefined) { - const federations = await api.getFederations(); - fed = federations.find((item) => item.id === id) || null; - federationCache.current.set(id, fed); - } - if (cancelled || currentRequest !== requestSeq.current) return; - - setFederation(fed ?? null); - - let observedGateways: GatewayInfo[] = []; - let observedError: string | null = null; - try { - observedGateways = await api.getFederationGateways(id, timeWindow); - } catch (observedErr: unknown) { - observedError = - observedErr instanceof Error - ? observedErr.message - : `Failed to fetch gateways for federation ${id}`; - } - if (cancelled || currentRequest !== requestSeq.current) return; - - let liveGateways: GatewayInfo[] = []; - let liveError: string | null = null; - if (fed?.invite) { - const cachedLive = liveGatewayCache.current.get(fed.invite); - if (cachedLive) { - liveGateways = cachedLive.gateways; - liveError = cachedLive.error; - } else { - try { - liveGateways = await api.getFederationGatewaysByInvite(fed.invite); - } catch (liveErr: unknown) { - liveError = - liveErr instanceof Error - ? liveErr.message - : 'Invite-based gateway lookup failed.'; - } - liveGatewayCache.current.set(fed.invite, { - gateways: liveGateways, - error: liveError, - }); - } - } - if (cancelled || currentRequest !== requestSeq.current) return; - - const selection = selectGatewayData( - observedGateways, - liveGateways, - observedError, - liveError, - Boolean(fed?.invite), - ); - setGateways(selection.gateways); - setGatewayWarning(selection.warning); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to load gateways'; - if (!hasLoadedOnce.current) { - setError(message); - } else { - setGatewayWarning({ - level: 'warning', - title: 'Refresh Failed', - message: 'Failed to refresh the selected time window.', - detail: message, - }); - } - } finally { - if (!cancelled && currentRequest === requestSeq.current) { - if (!hasLoadedOnce.current) { - setLoading(false); - hasLoadedOnce.current = true; - } - setWindowLoading(false); - } + loadInFlight.current = true; + setObservedLoading(true); + setTrendLoading(true); + + const gatewaysRequest = api.getFederationGateways(id, timeWindow, signal).then( + ({ data, stale }) => { + if (signal.aborted) return false; + setObserved({ window: timeWindow, gateways: data }); + setObservedError(null); + // An expired cache entry is old data, so it does not count as an update + if (!stale) setUpdatedAt(Date.now()); + setObservedLoading(false); + return stale; + }, + (err: unknown) => { + if (signal.aborted) return false; + setObservedError({ + window: timeWindow, + message: errorMessage(err, 'Failed to fetch gateways'), + network: err instanceof TypeError, + }); + setObservedLoading(false); + return false; + }, + ); + const trendRequest = api.getFederationGatewayUptimeTrend(id, timeWindow, signal).then( + ({ data, stale }) => { + if (signal.aborted) return false; + setTrend({ window: timeWindow, points: data }); + setTrendFailed(false); + setTrendLoading(false); + return stale; + }, + () => { + if (signal.aborted) return false; + setTrendFailed(true); + setTrendLoading(false); + return false; + }, + ); + + void Promise.all([gatewaysRequest, trendRequest]).then((staleness) => { + if (signal.aborted) return; + loadInFlight.current = false; + lastLoadAt.current = Date.now(); + if (!staleness.some(Boolean)) { + staleAttempts.current = 0; + return; } - })(); + // Nginx answered from an expired entry while it rebuilds it in the background, + // which takes up to ~20 s for 90D, so ask again with a growing delay + if (staleAttempts.current >= MAX_STALE_RETRIES) return; + retryTimer = setTimeout(() => setRefreshKey((key) => key + 1), STALE_RETRY_MS * 2 ** staleAttempts.current); + staleAttempts.current += 1; + }); + + return () => { + controller.abort(); + clearTimeout(retryTimer); + loadInFlight.current = false; + }; + }, [id, timeWindow, reloadKey, refreshKey]); + // The federation's own registry only adds details, so it never holds up the page + const invite = federation?.invite; + const federationOffline = federation?.health === 'offline'; + useEffect(() => { + // An offline federation's guardians can't answer; the lookup would hang for a minute + if (!invite || federationOffline) return; + const controller = new AbortController(); + let cancelled = false; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, LIVE_LOOKUP_TIMEOUT_MS); + setLive({ status: 'pending' }); + api.getFederationGatewaysByInvite(invite, controller.signal) + .then((gateways) => { + if (!cancelled) setLive({ status: 'done', gateways, error: null }); + }) + .catch((err: unknown) => { + if (cancelled) return; + setLive({ + status: 'done', + gateways: [], + error: timedOut ? 'The live registry lookup timed out' : errorMessage(err, 'The live registry lookup failed'), + }); + }) + .finally(() => clearTimeout(timeout)); return () => { cancelled = true; + clearTimeout(timeout); + controller.abort(); + }; + }, [invite, federationOffline, liveKey]); + + // Keep statuses and "seen" times current, and refresh a minute after the last load + // finished. A load in flight is never interrupted, so slow windows can complete. + useEffect(() => { + const clock = setInterval(() => setNow(Date.now()), 30_000); + const refresh = setInterval(() => { + if (document.visibilityState !== 'visible' || loadInFlight.current) return; + if (Date.now() - lastLoadAt.current >= REFRESH_INTERVAL_MS) setRefreshKey((key) => key + 1); + }, REFRESH_CHECK_MS); + const onVisibilityChange = () => { + if (document.visibilityState !== 'visible') return; + setNow(Date.now()); + if (!loadInFlight.current && Date.now() - lastLoadAt.current > 30_000) setRefreshKey((key) => key + 1); }; - }, [id, timeWindow]); - - const windowMinutes = useMemo(() => { - switch (timeWindow) { - case '1h': - return 60; - case '24h': - return 24 * 60; - case '7d': - return 7 * 24 * 60; - case '30d': - return 30 * 24 * 60; - case '90d': - default: - return 90 * 24 * 60; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + clearInterval(clock); + clearInterval(refresh); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, []); + + const liveLookup = useMemo(() => { + if (invite && !federationOffline) return live; + return { status: federation === undefined && !federationFailed ? 'pending' : 'skipped' }; + }, [federation, federationFailed, federationOffline, invite, live]); + + // An error only describes the window it happened in, never one that is still loading + const currentError = observedError?.window === timeWindow ? observedError : null; + const dataWindow = observed?.window ?? timeWindow; + const windowMinutes = WINDOW_MINUTES[dataWindow]; + const selection = useMemo(() => selectGatewayData({ + observed: observed?.gateways ?? null, + observedError: currentError, + live: liveLookup, + federationFailed, + federationOffline, + dataWindow, + }), [currentError, dataWindow, federationFailed, federationOffline, liveLookup, observed]); + const gateways = selection.gateways; + + // Try again repeats only what failed, and is ignored while an attempt is running + const retryBusy = observedLoading; + const retry = () => { + if (retryBusy) return; + focusAfterRetry.current = true; + setReloadKey((key) => key + 1); + if (federationFailed) setFederationKey((key) => key + 1); + if (live.status === 'done' && live.error) setLiveKey((key) => key + 1); + }; + + // When a retry succeeds, its banner disappears together with the focused Try again + // button; put keyboard focus on the page heading instead of losing it + useEffect(() => { + if (focusAfterRetry.current && !observedLoading && document.activeElement === document.body) { + focusAfterRetry.current = false; + headingRef.current?.focus(); } - }, [timeWindow]); + }); const rows = useMemo(() => { return gateways .map((gateway) => { const firstSeenDate = parseTimestamp(gateway.first_seen); const lastSeenDate = parseTimestamp(gateway.last_seen); - const status = getGatewayStatus(lastSeenDate); + const status = getGatewayStatus(lastSeenDate, now); const minutesSinceLastSeen = lastSeenDate - ? (Date.now() - lastSeenDate.getTime()) / (1000 * 60) + ? (now - lastSeenDate.getTime()) / (1000 * 60) : null; const observedUptime = gateway.uptime_window; const hasObservedSamples = Boolean(observedUptime && observedUptime.sample_count > 0); @@ -578,7 +749,7 @@ export function FederationGateways() { searchText, }; }); - }, [gateways, windowMinutes]); + }, [gateways, now, windowMinutes]); const totals = useMemo(() => { const total = rows.length; @@ -634,7 +805,7 @@ export function FederationGateways() { useEffect(() => { setVisibleGatewayCount(INITIAL_RENDER_COUNT); - }, [gatewayFilter, gatewaySearch, gatewaySort, id, sortDirection]); + }, [gatewayFilter, gatewaySearch, gatewaySort, sortDirection]); useEffect(() => { if (!rawAnnouncement) return undefined; @@ -658,70 +829,135 @@ export function FederationGateways() { [filteredRows, visibleGatewayCount], ); - const uptimeTrendOption = useMemo(() => ({ + const { theme } = useTheme(); + const prefersDark = usePrefersDark(); + const darkMode = theme === 'dark' || (theme === 'auto' && prefersDark); + const chartColors = useMemo(() => (darkMode + ? { label: '#9ca3af', axis: '#4b5563', grid: '#374151', line: '#60a5fa', area: 'rgba(96, 165, 250, 0.15)' } + : { label: '#6b7280', axis: '#d1d5db', grid: '#e5e7eb', line: '#2563eb', area: 'rgba(37, 99, 235, 0.12)' }), [darkMode]); + + // A window switch shows the loading state rather than the previous window's trend + const uptimeTrend = trend?.window === timeWindow ? trend.points : EMPTY_TREND; + const uptimeTrendOption = useMemo(() => ({ animationDuration: 200, grid: { top: 18, right: 18, bottom: 30, left: 42 }, - tooltip: { trigger: 'axis', valueFormatter: (value: number | string) => `${Number(value).toFixed(1)}%` }, + tooltip: { trigger: 'axis', valueFormatter: (value) => `${Number(value).toFixed(1)}%` }, xAxis: { type: 'category', boundaryGap: false, - data: uptimeTrend.map((point) => new Date(point.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })), - axisLabel: { color: '#6b7280', fontSize: 11 }, axisLine: { lineStyle: { color: '#d1d5db' } }, + // Days are bucketed in the server's time zone (midnight there is e.g. 22:00Z). Half a + // day later, formatted in UTC, names that same calendar day for every viewer. + data: uptimeTrend.map((point) => new Date(Date.parse(point.day) + 12 * 60 * 60 * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })), + axisLabel: { color: chartColors.label, fontSize: 11 }, axisLine: { lineStyle: { color: chartColors.axis } }, }, - yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%', color: '#6b7280', fontSize: 11 }, splitLine: { lineStyle: { color: '#e5e7eb' } } }, - series: [{ name: 'Gateway availability', type: 'line', smooth: true, symbol: uptimeTrend.length > 45 ? 'none' : 'circle', symbolSize: 5, data: uptimeTrend.map((point) => Number(point.uptime_pct.toFixed(2))), lineStyle: { color: '#2563eb', width: 2.5 }, itemStyle: { color: '#2563eb' }, areaStyle: { color: 'rgba(37, 99, 235, 0.12)' } }], - }), [uptimeTrend]); - - if (loading) { - return ( -
-
Loading gateways...
-
- ); - } + yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%', color: chartColors.label, fontSize: 11 }, splitLine: { lineStyle: { color: chartColors.grid } } }, + series: [{ name: 'Gateway availability', type: 'line', smooth: true, symbol: uptimeTrend.length > 45 ? 'none' : 'circle', symbolSize: 5, data: uptimeTrend.map((point) => Number(point.uptime_pct.toFixed(2))), lineStyle: { color: chartColors.line, width: 2.5 }, itemStyle: { color: chartColors.line }, areaStyle: { color: chartColors.area } }], + }), [chartColors, uptimeTrend]); - if (error) { + if (federation === null && observed !== null && observed.gateways.length === 0) { return ( -
-
Error: {error}
+
+

This federation is not tracked by the observer.

+ ← All federations
); } - const federationName = federation?.name || 'Federation'; - const metricsWindow = (rows.find((row) => row.metrics_window)?.metrics_window ?? timeWindow) + const metricsWindow = (rows.find((row) => row.metrics_window)?.metrics_window ?? dataWindow) .toUpperCase(); + // Until the recorded gateways arrive there are no numbers to show, only the controls + const hasData = observed !== null || gateways.length > 0; + const switchingWindow = observed !== null && dataWindow !== timeWindow && observedLoading; + const trendPlaceholder = trend?.window === timeWindow + ? 'No gateway poll history is available for this window.' + : trendLoading + ? 'Loading availability trend…' + : trendFailed ? 'The availability trend could not be loaded.' : ''; + const windowStatus = observedLoading && (observed === null || dataWindow !== timeWindow) + ? `Loading ${timeWindow.toUpperCase()}…` + : observed === null + ? ' ' + : dataWindow !== timeWindow + ? `Showing ${dataWindow.toUpperCase()}` + : updatedAt ? `Updated ${formatRelative(new Date(updatedAt), now)}` : 'Refreshing…'; return (
← Federation details
-
Lightning gateway observatory

{federationName}

{(['24h', '7d', '30d', '90d'] as GatewayWindow[]).map((window) => )}
{windowLoading ? 'Refreshing…' : `Observed over ${timeWindow.toUpperCase()}`}
-
{totals.total} gateways● {totals.online} online● {totals.degraded} degraded● {totals.offline} offline{totals.vetted} vetted{avgUptime.toFixed(1)}% uptime ({avgCoverage.toFixed(0)}% observed)
+
+
+
Lightning gateway observatory
+

+ {federation?.name || (federation === undefined && !federationFailed + ?

+
+
+
+ {GATEWAY_WINDOWS.map((option) => ( + + ))} +
+
{windowStatus}
+
+
+ {hasData && ( +
{totals.total} gateways● {totals.online} online● {totals.degraded} degraded● {totals.offline} offline{totals.unknown > 0 && ● {totals.unknown} unknown}{totals.vetted} vetted{avgCoverage > 0 ? <>{avgUptime.toFixed(1)}% uptime ({avgCoverage.toFixed(0)}% observed) : 'No uptime data'}
+ )}
- {gatewayWarning && } + {(hasData || currentError) && selection.warning && ( + + )} + {!hasData && !currentError && ( +
Loading gateways…
+ )} + + {hasData && (<>
-

Gateway availability trend ({timeWindow.toUpperCase()})

Each point combines every gateway poll snapshot recorded on that calendar day.

{uptimeTrend.length > 0 && {uptimeTrend.length} calendar day{uptimeTrend.length === 1 ? '' : 's'} with data}
- {uptimeTrendLoading ?
Loading availability trend…
: uptimeTrend.length > 0 ? Loading chart…
}> :
No gateway poll history is available for this window.
} +

Gateway availability trend ({timeWindow.toUpperCase()})

Each point combines every gateway poll snapshot recorded on that calendar day.

{uptimeTrend.length > 0 && {uptimeTrend.length} calendar day{uptimeTrend.length === 1 ? '' : 's'} with data}
+ {uptimeTrend.length > 0 ? ( +
}> + Loading chart…}> + + + + ) : ( +
{trendPlaceholder}
+ )} -
+
-

Gateway directory

{filteredRows.length} of {rows.length} gateways · every row includes its 30-bucket availability strip, ordered oldest to newest.

setGatewaySearch(event.target.value)} aria-label="Search gateways" placeholder="Search gateway, node, endpoint" className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />
-
{(['all', 'online', 'degraded', 'offline', 'unknown'] as GatewayFilter[]).map((filter) => )}
+

Gateway directory

{filteredRows.length} of {rows.length} gateways · every row includes its 30-bucket availability strip, ordered oldest to newest.

setGatewaySearch(event.target.value)} aria-label="Search gateways" placeholder="Search gateway, node, endpoint" className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />
+
{GATEWAY_FILTERS.map((filter) => )}
OnlineDegradedOfflineUnknown
-
- - + {/* Phones get one stacked card per gateway instead of a 960px table behind a sideways scroll */} +
+
+ - + @@ -730,11 +966,11 @@ export function FederationGateways() { - + {filteredRows.length === 0 && ( - - + )} @@ -742,9 +978,9 @@ export function FederationGateways() { {visibleRows.map((gateway) => ( - - - - - )} From 8fda80a33f3c4345c0f057d27f9b35f9b26d0cf8 Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Fri, 9 Oct 2026 23:35:25 +0530 Subject: [PATCH 5/7] fix(gateways): store gateway polls again The retired-gateway filter compared last_seen with `$2 - make_interval(...)`. Postgres can't infer $2's type there and read it as an interval, so every poll failed with "operator does not exist: timestamp with time zone >= interval" and rolled back: no gateway was marked seen after the upgrade. Cast the poll time explicitly. Co-Authored-By: Claude Opus 5.5 --- fmo_server/src/federation/gateways.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs index 517f707..b195fbb 100644 --- a/fmo_server/src/federation/gateways.rs +++ b/fmo_server/src/federation/gateways.rs @@ -328,7 +328,7 @@ impl FederationObserver { SELECT g.gateway_id, FALSE AS is_seen FROM gateways g WHERE g.federation_id = $1 - AND g.last_seen >= $2 - make_interval(days => $4) + AND g.last_seen >= $2::timestamptz - make_interval(days => $4) AND NOT EXISTS ( SELECT 1 FROM current_gateway_ids c From c59b2fee10f1cef6c2d98468a36117c08ec996ec Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Fri, 9 Oct 2026 23:35:39 +0530 Subject: [PATCH 6/7] perf(gateways): serve the gateway page from precomputed overviews Each request ran the gateway and trend queries from scratch, 4 s for 90D, so the page leaned on Nginx serving expired cache entries and had to detect them and retry. - A background task rebuilds every federation's gateways and uptime trend for every window every 5 minutes, one query at a time, and keeps them in memory. Requests answer from that in ~1 ms. A missing entry (right after a restart) is computed on the spot but not stored, so unknown federation IDs can't grow the cache. - New GET /federations/:id/gateways/overview?window= returns both lists computed at the same moment, with computed_at. ?include=gateways or ?include=trend returns only that part. - Remove /gateways?window= and /gateways/uptime-trend, now unused. - The page makes one request, shows "Updated" from computed_at, and drops the stale-cache detection and retries. Co-Authored-By: Claude Opus 5.5 --- fmo_api_types/src/lib.rs | 15 ++ .../src/pages/FederationGateways.tsx | 83 +++------ fmo_frontend_react/src/services/api.ts | 46 +---- fmo_frontend_react/src/types/api.ts | 9 + fmo_server/src/federation/gateways.rs | 161 +++++++++++++++--- fmo_server/src/federation/mod.rs | 7 +- fmo_server/src/federation/observer.rs | 11 ++ 7 files changed, 202 insertions(+), 130 deletions(-) diff --git a/fmo_api_types/src/lib.rs b/fmo_api_types/src/lib.rs index 416d080..328607d 100644 --- a/fmo_api_types/src/lib.rs +++ b/fmo_api_types/src/lib.rs @@ -138,3 +138,18 @@ pub struct GatewayUptimeTrendPoint { pub total_samples: u64, pub uptime_pct: f64, } + +/// A federation's gateways and their availability trend for one window, +/// computed at the same moment so both always agree +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayOverview { + /// The window label, e.g. `7d` + pub window: String, + pub computed_at: DateTime, + /// Left out when `include` doesn't ask for `gateways` + #[serde(skip_serializing_if = "Option::is_none")] + pub gateways: Option>, + /// Left out when `include` doesn't ask for `trend` + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_trend: Option>, +} diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx index 3ac16ff..5d54659 100644 --- a/fmo_frontend_react/src/pages/FederationGateways.tsx +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -35,11 +35,9 @@ const WINDOW_MINUTES: Record = { '30d': 30 * 24 * 60, '90d': 90 * 24 * 60, }; -// Gateways are polled every 5 minutes and Nginx caches answers for 6 +// Gateways are polled and their overview rebuilt every 5 minutes on the server const REFRESH_INTERVAL_MS = 60_000; const REFRESH_CHECK_MS = 10_000; -const STALE_RETRY_MS = 2_500; // doubled after each stale answer -const MAX_STALE_RETRIES = 4; const LIVE_LOOKUP_TIMEOUT_MS = 10_000; const EMPTY_TREND: GatewayUptimeTrendPoint[] = []; const RETIRED_AFTER_MINUTES = 7 * 24 * 60; @@ -78,7 +76,7 @@ function parseTimestamp(value?: string): Date | null { function getGatewayStatus(lastSeen: Date | null, now: number): GatewayStatus { if (!lastSeen) return 'unknown'; const minutes = (now - lastSeen.getTime()) / (1000 * 60); - // Gateways are polled every 5 minutes and answers are cached for up to 6, so allow + // Gateways are polled every 5 minutes and the overview is rebuilt every 5, so allow // up to three polls before a healthy gateway stops counting as online if (minutes <= 15) return 'online'; if (minutes <= 30) return 'degraded'; @@ -458,8 +456,6 @@ export function FederationGateways() { const [live, setLive] = useState({ status: 'pending' }); const [liveKey, setLiveKey] = useState(0); const [trend, setTrend] = useState<{ window: SelectableWindow; points: GatewayUptimeTrendPoint[] } | null>(null); - const [trendFailed, setTrendFailed] = useState(false); - const [trendLoading, setTrendLoading] = useState(true); const [updatedAt, setUpdatedAt] = useState(null); const [now, setNow] = useState(() => Date.now()); const [reloadKey, setReloadKey] = useState(0); // Try again @@ -469,8 +465,6 @@ export function FederationGateways() { const [rawAnnouncement, setRawAnnouncement] = useState(null); const loadInFlight = useRef(false); const lastLoadAt = useRef(0); // when the last load finished - const staleAttempts = useRef(0); - const staleScope = useRef(''); const focusAfterRetry = useRef(false); const headingRef = useRef(null); @@ -518,72 +512,35 @@ export function FederationGateways() { useEffect(() => { const controller = new AbortController(); const { signal } = controller; - let retryTimer: ReturnType | undefined; - // A new window or a Try again starts over with the stale retries - const scope = `${timeWindow}:${reloadKey}`; - if (staleScope.current !== scope) { - staleScope.current = scope; - staleAttempts.current = 0; - } loadInFlight.current = true; setObservedLoading(true); - setTrendLoading(true); - const gatewaysRequest = api.getFederationGateways(id, timeWindow, signal).then( - ({ data, stale }) => { - if (signal.aborted) return false; - setObserved({ window: timeWindow, gateways: data }); + // The table and the trend come in one answer, so they always describe the same moment + api.getFederationGatewayOverview(id, timeWindow, signal) + .then((overview) => { + if (signal.aborted) return; + setObserved({ window: timeWindow, gateways: overview.gateways ?? [] }); + setTrend({ window: timeWindow, points: overview.uptime_trend ?? [] }); setObservedError(null); - // An expired cache entry is old data, so it does not count as an update - if (!stale) setUpdatedAt(Date.now()); - setObservedLoading(false); - return stale; - }, - (err: unknown) => { - if (signal.aborted) return false; + setUpdatedAt(Date.parse(overview.computed_at)); + }) + .catch((err: unknown) => { + if (signal.aborted) return; setObservedError({ window: timeWindow, message: errorMessage(err, 'Failed to fetch gateways'), network: err instanceof TypeError, }); + }) + .finally(() => { + if (signal.aborted) return; setObservedLoading(false); - return false; - }, - ); - const trendRequest = api.getFederationGatewayUptimeTrend(id, timeWindow, signal).then( - ({ data, stale }) => { - if (signal.aborted) return false; - setTrend({ window: timeWindow, points: data }); - setTrendFailed(false); - setTrendLoading(false); - return stale; - }, - () => { - if (signal.aborted) return false; - setTrendFailed(true); - setTrendLoading(false); - return false; - }, - ); - - void Promise.all([gatewaysRequest, trendRequest]).then((staleness) => { - if (signal.aborted) return; - loadInFlight.current = false; - lastLoadAt.current = Date.now(); - if (!staleness.some(Boolean)) { - staleAttempts.current = 0; - return; - } - // Nginx answered from an expired entry while it rebuilds it in the background, - // which takes up to ~20 s for 90D, so ask again with a growing delay - if (staleAttempts.current >= MAX_STALE_RETRIES) return; - retryTimer = setTimeout(() => setRefreshKey((key) => key + 1), STALE_RETRY_MS * 2 ** staleAttempts.current); - staleAttempts.current += 1; - }); + loadInFlight.current = false; + lastLoadAt.current = Date.now(); + }); return () => { controller.abort(); - clearTimeout(retryTimer); loadInFlight.current = false; }; }, [id, timeWindow, reloadKey, refreshKey]); @@ -880,9 +837,9 @@ export function FederationGateways() { const switchingWindow = observed !== null && dataWindow !== timeWindow && observedLoading; const trendPlaceholder = trend?.window === timeWindow ? 'No gateway poll history is available for this window.' - : trendLoading + : observedLoading ? 'Loading availability trend…' - : trendFailed ? 'The availability trend could not be loaded.' : ''; + : currentError ? 'The availability trend could not be loaded.' : ''; const windowStatus = observedLoading && (observed === null || dataWindow !== timeWindow) ? `Loading ${timeWindow.toUpperCase()}…` : observed === null diff --git a/fmo_frontend_react/src/services/api.ts b/fmo_frontend_react/src/services/api.ts index 77725b8..5090c47 100644 --- a/fmo_frontend_react/src/services/api.ts +++ b/fmo_frontend_react/src/services/api.ts @@ -2,7 +2,7 @@ import type { FedimintTotals, FederationSummary, GatewayInfo, - GatewayUptimeTrendPoint, + GatewayOverview, GatewayWindow, } from '../types/api'; @@ -33,28 +33,12 @@ export const api = { return response.json(); }, - async getFederationGateways( - id: string, - window: GatewayWindow, - signal?: AbortSignal, - ): Promise> { - return fetchCached( - `${BASE_URL}/federations/${id}/gateways?window=${encodeURIComponent(window)}`, - 'Failed to fetch gateways', - signal, - ); - }, - - async getFederationGatewayUptimeTrend( - id: string, - window: GatewayWindow, - signal?: AbortSignal, - ): Promise> { - return fetchCached( - `${BASE_URL}/federations/${id}/gateways/uptime-trend?window=${encodeURIComponent(window)}`, - 'Failed to fetch the gateway uptime trend', - signal, - ); + async getFederationGatewayOverview(id: string, window: GatewayWindow, signal?: AbortSignal): Promise { + const response = await fetch(`${BASE_URL}/federations/${id}/gateways/overview?window=${encodeURIComponent(window)}`, { signal }); + if (!response.ok) { + throw new Error(`Failed to fetch gateways (${response.status})`); + } + return response.json(); }, async getFederationGatewaysByInvite(inviteCode: string, signal?: AbortSignal): Promise { @@ -66,19 +50,3 @@ export const api = { return response.json(); }, }; - -export interface CachedResponse { - data: T; - // Nginx answered from an expired cache entry and is refreshing it in the background - stale: boolean; -} - -async function fetchCached(url: string, errorMessage: string, signal?: AbortSignal): Promise> { - const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`${errorMessage} (${response.status})`); - } - // Only readable on the same origin, which is how the site is served - const cacheStatus = response.headers.get('X-Cache-Status'); - return { data: await response.json(), stale: cacheStatus === 'STALE' || cacheStatus === 'UPDATING' }; -} diff --git a/fmo_frontend_react/src/types/api.ts b/fmo_frontend_react/src/types/api.ts index 25f0391..10d2395 100644 --- a/fmo_frontend_react/src/types/api.ts +++ b/fmo_frontend_react/src/types/api.ts @@ -63,4 +63,13 @@ export interface GatewayUptimeTrendPoint { export type GatewayWindow = '1h' | '24h' | '7d' | '30d' | '90d'; +// Gateways and their trend for one window, computed together on the server +export interface GatewayOverview { + window: GatewayWindow; + computed_at: string; + // Both are present unless the request narrows them with ?include= + gateways?: GatewayInfo[]; + uptime_trend?: GatewayUptimeTrendPoint[]; +} + export type FederationHealth = 'online' | 'degraded' | 'offline'; diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs index b195fbb..a1577a9 100644 --- a/fmo_server/src/federation/gateways.rs +++ b/fmo_server/src/federation/gateways.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; -use std::time::Duration; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; use anyhow::{bail, Context}; use axum::extract::{Path, Query, State}; @@ -13,7 +14,8 @@ use fedimint_core::module::ApiRequestErased; use fedimint_ln_common::federation_endpoint_constants::LIST_GATEWAYS_ENDPOINT; use fedimint_ln_common::LightningGatewayAnnouncement; use fmo_api_types::{ - GatewayActivityMetrics, GatewayInfo, GatewayUptimeMetrics, GatewayUptimeTrendPoint, + GatewayActivityMetrics, GatewayInfo, GatewayOverview, GatewayUptimeMetrics, + GatewayUptimeTrendPoint, }; use futures::future::join_all; use serde::Deserialize; @@ -29,9 +31,16 @@ const GATEWAY_PRUNE_INTERVAL_HOURS: i64 = 6; // gets no more "not seen" samples, and later ones don't count against uptime or // the trend const GATEWAY_RETIRED_AFTER_DAYS: i32 = 7; +// Matches the poll interval, so an overview is never more than one poll behind +const GATEWAY_OVERVIEW_REFRESH_MINUTES: u64 = 5; -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum GatewayMetricsWindow { +/// Overviews for every federation and window, rebuilt in the background so +/// requests never wait for the queries (90D takes seconds) +pub(crate) type GatewayOverviewCache = + Arc>>>; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) enum GatewayMetricsWindow { H1, H24, D7, @@ -40,6 +49,9 @@ enum GatewayMetricsWindow { } impl GatewayMetricsWindow { + // The page's default window first, so it is ready soonest after a restart + const ALL: [Self; 5] = [Self::D7, Self::H24, Self::D30, Self::D90, Self::H1]; + fn parse(value: Option<&str>) -> anyhow::Result { match value.unwrap_or("7d") { "1h" => Ok(Self::H1), @@ -77,6 +89,8 @@ impl GatewayMetricsWindow { #[derive(Debug, Deserialize)] pub(super) struct GetFederationGatewaysParams { window: Option, + /// Comma-separated parts to return: `gateways`, `trend`. Both by default. + include: Option, } pub(crate) async fn fetch_gateways_for_config( @@ -377,10 +391,95 @@ impl FederationObserver { Ok(()) } + /// Background task: rebuild every federation's gateway overviews. Runs in + /// a loop until cancelled. + pub(crate) async fn refresh_gateway_overviews(self) { + let mut interval = + tokio::time::interval(Duration::from_secs(GATEWAY_OVERVIEW_REFRESH_MINUTES * 60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let start = Instant::now(); + let federations = match self.list_federations().await { + Ok(federations) => federations, + Err(e) => { + warn!("Failed to list federations for gateway overviews: {e:?}"); + continue; + } + }; + // One query at a time keeps the load on the database steady + for window in GatewayMetricsWindow::ALL { + for federation in &federations { + let federation_id = federation.federation_id; + match self.compute_gateway_overview(federation_id, window).await { + Ok(overview) => { + self.gateway_overviews() + .write() + .expect("gateway overview lock poisoned") + .insert((federation_id, window), Arc::new(overview)); + } + Err(e) => warn!( + "Failed to compute {} gateway overview for {}: {e:?}", + window.label(), + federation_id + ), + } + } + } + info!( + "Gateway overviews refreshed in {:.2}s", + start.elapsed().as_secs_f64() + ); + } + } + + async fn gateway_overview( + &self, + federation_id: FederationId, + window: GatewayMetricsWindow, + ) -> anyhow::Result> { + let cached = self + .gateway_overviews() + .read() + .expect("gateway overview lock poisoned") + .get(&(federation_id, window)) + .cloned(); + // Only right after a restart, or for a federation added since the last + // refresh. Not cached here, so unknown IDs can't grow the cache. + match cached { + Some(overview) => Ok(overview), + None => Ok(Arc::new( + self.compute_gateway_overview(federation_id, window).await?, + )), + } + } + + async fn compute_gateway_overview( + &self, + federation_id: FederationId, + window: GatewayMetricsWindow, + ) -> anyhow::Result { + let computed_at = Utc::now(); + let window_start = computed_at - window.duration(); + Ok(GatewayOverview { + window: window.label().to_owned(), + computed_at, + gateways: Some( + self.list_federation_gateways(federation_id, window, window_start) + .await?, + ), + uptime_trend: Some( + self.federation_gateway_uptime_trend(federation_id, window_start) + .await?, + ), + }) + } + async fn list_federation_gateways( &self, federation_id: FederationId, window: GatewayMetricsWindow, + window_start_utc: DateTime, ) -> anyhow::Result> { #[derive(postgres_from_row::FromRow)] struct GatewayRow { @@ -412,7 +511,6 @@ impl FederationObserver { let conn = self.connection().await?; let federation_id_bytes = federation_id.consensus_encode_to_vec(); - let window_start_utc: DateTime = Utc::now() - window.duration(); let window_start_naive = window_start_utc.naive_utc(); let metrics_window = window.label().to_owned(); @@ -631,7 +729,7 @@ impl FederationObserver { async fn federation_gateway_uptime_trend( &self, federation_id: FederationId, - window: GatewayMetricsWindow, + window_start: DateTime, ) -> anyhow::Result> { #[derive(postgres_from_row::FromRow)] struct TrendRow { @@ -642,7 +740,6 @@ impl FederationObserver { let conn = self.connection().await?; let federation_id_bytes = federation_id.consensus_encode_to_vec(); - let window_start = Utc::now() - window.duration(); let rows = query::( &conn, "SELECT @@ -685,28 +782,44 @@ impl FederationObserver { } } -pub(super) async fn get_federation_gateways( - Path(federation_id): Path, - Query(params): Query, - State(state): State, -) -> crate::error::Result>> { - let window = GatewayMetricsWindow::parse(params.window.as_deref())?; - Ok(state - .federation_observer - .list_federation_gateways(federation_id, window) - .await? - .into()) +/// Which parts of the overview to return, as (gateways, trend) +fn parse_overview_include(include: Option<&str>) -> anyhow::Result<(bool, bool)> { + let Some(include) = include else { + return Ok((true, true)); + }; + let (mut gateways, mut trend) = (false, false); + for part in include.split(',').map(str::trim) { + match part { + "gateways" => gateways = true, + "trend" => trend = true, + invalid => { + bail!("Invalid include '{invalid}'. Supported values: gateways, trend") + } + } + } + Ok((gateways, trend)) } -pub(super) async fn get_federation_gateway_uptime_trend( +pub(super) async fn get_federation_gateway_overview( Path(federation_id): Path, Query(params): Query, State(state): State, -) -> crate::error::Result>> { +) -> crate::error::Result> { let window = GatewayMetricsWindow::parse(params.window.as_deref())?; - Ok(state + let (include_gateways, include_trend) = parse_overview_include(params.include.as_deref())?; + let overview = state .federation_observer - .federation_gateway_uptime_trend(federation_id, window) - .await? - .into()) + .gateway_overview(federation_id, window) + .await?; + // Only the requested parts are copied out of the shared cached overview + Ok(Json(GatewayOverview { + window: overview.window.clone(), + computed_at: overview.computed_at, + gateways: include_gateways + .then(|| overview.gateways.clone()) + .flatten(), + uptime_trend: include_trend + .then(|| overview.uptime_trend.clone()) + .flatten(), + })) } diff --git a/fmo_server/src/federation/mod.rs b/fmo_server/src/federation/mod.rs index c728eb4..44b6215 100644 --- a/fmo_server/src/federation/mod.rs +++ b/fmo_server/src/federation/mod.rs @@ -20,7 +20,7 @@ use fmo_api_types::{FederationSummary, FedimintTotals, NonceSpendInfo, NoncesReq use serde::Deserialize; use serde_json::json; -use crate::federation::gateways::{get_federation_gateway_uptime_trend, get_federation_gateways}; +use crate::federation::gateways::get_federation_gateway_overview; use crate::federation::guardians::get_federation_health; use crate::federation::meta::get_federation_meta; use crate::federation::session::{count_sessions, list_sessions}; @@ -57,10 +57,9 @@ pub fn get_federations_routes() -> Router { "/:federation_id/transactions/histogram", get(transaction_histogram), ) - .route("/:federation_id/gateways", get(get_federation_gateways)) .route( - "/:federation_id/gateways/uptime-trend", - get(get_federation_gateway_uptime_trend), + "/:federation_id/gateways/overview", + get(get_federation_gateway_overview), ) .route("/:federation_id/utxos", get(get_federation_utxos)) .route("/:federation_id/sessions", get(list_sessions)) diff --git a/fmo_server/src/federation/observer.rs b/fmo_server/src/federation/observer.rs index a38978d..0addb55 100644 --- a/fmo_server/src/federation/observer.rs +++ b/fmo_server/src/federation/observer.rs @@ -41,6 +41,7 @@ use tracing::{debug, error, info_span, warn, Instrument}; use crate::config::meta::{ConsensusMetaCache, MetaFieldsExt}; use crate::db::DbMigration; use crate::federation::db::Federation; +use crate::federation::gateways::GatewayOverviewCache; use crate::federation::{db, decoders_from_config, instance_to_kind}; use crate::util::{execute, query, query_one, query_opt, query_value}; use crate::{migration, migration_backfill, schema_setup}; @@ -53,6 +54,7 @@ pub struct FederationObserver { task_group: TaskGroup, consensus_meta_cache: ConsensusMetaCache, connectors: ConnectorRegistry, + gateway_overviews: GatewayOverviewCache, } impl FederationObserver { @@ -78,6 +80,7 @@ impl FederationObserver { task_group: Default::default(), consensus_meta_cache: Default::default(), connectors, + gateway_overviews: Default::default(), }; slf.setup_schema().await?; @@ -92,6 +95,10 @@ impl FederationObserver { .spawn_cancellable("sync nostr events", Self::sync_nostr_events(slf.clone())); slf.task_group .spawn_cancellable("refresh views", Self::refresh_views(slf.clone())); + slf.task_group.spawn_cancellable( + "refresh gateway overviews", + Self::refresh_gateway_overviews(slf.clone()), + ); Ok(slf) } @@ -100,6 +107,10 @@ impl FederationObserver { &self.connectors } + pub(crate) fn gateway_overviews(&self) -> &GatewayOverviewCache { + &self.gateway_overviews + } + async fn spawn_observer(&self, federation: Federation) { let slf = self.clone(); From b6f7542a144638c62a3e3b512741064b7d333ce9 Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Sat, 10 Oct 2026 12:08:20 +0530 Subject: [PATCH 7/7] perf(gateways): refresh long overview windows less often and drop 1h On staging the overview task took ~75 s of every 5-minute cycle and raised the VPS CPU from ~27% to ~35%, slowing the other database work too. Most of that went into 30D and 90D, which barely change in 5 minutes. - 24h and 7D still refresh every 5 minutes, 30D every 30 and 90D every hour. The first cycle after a restart builds all of them. - Remove the 1h window: the page never offered it and it is shorter than the 5-minute poll interval. window=1h now returns the usual error. Co-Authored-By: Claude Opus 5.5 --- .../src/pages/FederationGateways.tsx | 1 - fmo_frontend_react/src/types/api.ts | 2 +- fmo_server/src/federation/gateways.rs | 48 +++++++++++++------ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx index 5d54659..3b9576e 100644 --- a/fmo_frontend_react/src/pages/FederationGateways.tsx +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -15,7 +15,6 @@ import type { EChartsOption } from 'echarts'; type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'retired'; type UptimeStripStatus = 'online' | 'degraded' | 'offline' | 'unknown'; -// The backend also accepts 1h, which is too short for its 5-minute polls const GATEWAY_WINDOWS = ['24h', '7d', '30d', '90d'] as const; type SelectableWindow = (typeof GATEWAY_WINDOWS)[number]; // 'all' means every active gateway; retired ones have their own filter diff --git a/fmo_frontend_react/src/types/api.ts b/fmo_frontend_react/src/types/api.ts index 10d2395..95bbe6a 100644 --- a/fmo_frontend_react/src/types/api.ts +++ b/fmo_frontend_react/src/types/api.ts @@ -61,7 +61,7 @@ export interface GatewayUptimeTrendPoint { uptime_pct: number; } -export type GatewayWindow = '1h' | '24h' | '7d' | '30d' | '90d'; +export type GatewayWindow = '24h' | '7d' | '30d' | '90d'; // Gateways and their trend for one window, computed together on the server export interface GatewayOverview { diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs index a1577a9..ee15784 100644 --- a/fmo_server/src/federation/gateways.rs +++ b/fmo_server/src/federation/gateways.rs @@ -31,8 +31,9 @@ const GATEWAY_PRUNE_INTERVAL_HOURS: i64 = 6; // gets no more "not seen" samples, and later ones don't count against uptime or // the trend const GATEWAY_RETIRED_AFTER_DAYS: i32 = 7; -// Matches the poll interval, so an overview is never more than one poll behind -const GATEWAY_OVERVIEW_REFRESH_MINUTES: u64 = 5; +// Matches the poll interval, so short windows are never more than one poll +// behind; longer windows refresh every few ticks (see refresh_every_ticks) +const GATEWAY_OVERVIEW_TICK_MINUTES: u64 = 5; /// Overviews for every federation and window, rebuilt in the background so /// requests never wait for the queries (90D takes seconds) @@ -41,7 +42,6 @@ pub(crate) type GatewayOverviewCache = #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] pub(crate) enum GatewayMetricsWindow { - H1, H24, D7, D30, @@ -50,24 +50,22 @@ pub(crate) enum GatewayMetricsWindow { impl GatewayMetricsWindow { // The page's default window first, so it is ready soonest after a restart - const ALL: [Self; 5] = [Self::D7, Self::H24, Self::D30, Self::D90, Self::H1]; + const ALL: [Self; 4] = [Self::D7, Self::H24, Self::D30, Self::D90]; fn parse(value: Option<&str>) -> anyhow::Result { match value.unwrap_or("7d") { - "1h" => Ok(Self::H1), "24h" => Ok(Self::H24), "7d" => Ok(Self::D7), "30d" => Ok(Self::D30), "90d" => Ok(Self::D90), - invalid => bail!( - "Invalid gateways window '{invalid}'. Supported values: 1h, 24h, 7d, 30d, 90d" - ), + invalid => { + bail!("Invalid gateways window '{invalid}'. Supported values: 24h, 7d, 30d, 90d") + } } } fn label(self) -> &'static str { match self { - Self::H1 => "1h", Self::H24 => "24h", Self::D7 => "7d", Self::D30 => "30d", @@ -77,13 +75,23 @@ impl GatewayMetricsWindow { fn duration(self) -> chrono::Duration { match self { - Self::H1 => chrono::Duration::hours(1), Self::H24 => chrono::Duration::hours(24), Self::D7 => chrono::Duration::days(7), Self::D30 => chrono::Duration::days(30), Self::D90 => chrono::Duration::days(90), } } + + /// How many overview ticks pass between refreshes. Five more minutes + /// barely move a 30- or 90-day figure, and those windows run the heaviest + /// queries. + fn refresh_every_ticks(self) -> u64 { + match self { + Self::H24 | Self::D7 => 1, + Self::D30 => 6, + Self::D90 => 12, + } + } } #[derive(Debug, Deserialize)] @@ -395,11 +403,18 @@ impl FederationObserver { /// a loop until cancelled. pub(crate) async fn refresh_gateway_overviews(self) { let mut interval = - tokio::time::interval(Duration::from_secs(GATEWAY_OVERVIEW_REFRESH_MINUTES * 60)); + tokio::time::interval(Duration::from_secs(GATEWAY_OVERVIEW_TICK_MINUTES * 60)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // Tick 0 builds every window, so all are ready right after a restart + let mut tick: u64 = 0; loop { interval.tick().await; let start = Instant::now(); + let windows: Vec = GatewayMetricsWindow::ALL + .into_iter() + .filter(|window| tick.is_multiple_of(window.refresh_every_ticks())) + .collect(); + tick += 1; let federations = match self.list_federations().await { Ok(federations) => federations, Err(e) => { @@ -408,7 +423,7 @@ impl FederationObserver { } }; // One query at a time keeps the load on the database steady - for window in GatewayMetricsWindow::ALL { + for &window in &windows { for federation in &federations { let federation_id = federation.federation_id; match self.compute_gateway_overview(federation_id, window).await { @@ -427,8 +442,13 @@ impl FederationObserver { } } info!( - "Gateway overviews refreshed in {:.2}s", - start.elapsed().as_secs_f64() + "Gateway overviews refreshed in {:.2}s ({})", + start.elapsed().as_secs_f64(), + windows + .iter() + .map(|window| window.label()) + .collect::>() + .join(", ") ); } }
Gateway AvailabilityEndpoint
- No gateways match these filters. +
+ {rows.length === 0 ? 'No gateways to show.' : 'No gateways match these filters.'}
+
{gateway.lightning_alias || 'Unnamed Gateway'}
@@ -761,36 +997,44 @@ export function FederationGateways() { Node: {shortId(gateway.node_pub_key)}
+
{gateway.status} - seen {formatRelative(gateway.lastSeenDate)} + {gateway.lastSeenDate ? `seen ${formatRelative(gateway.lastSeenDate, now)}` : 'never seen'}
{gateway.coveragePct > 0 ? `${gateway.estimatedUptimePct.toFixed(1)}% uptime` : 'No samples'} {gateway.coveragePct.toFixed(0)}% observed
-
+ {/* One stop for keyboards and screen readers instead of 30 per row */} +
0 ? `${gateway.estimatedUptimePct.toFixed(1)}% uptime` : 'no samples'}, ${gateway.coveragePct.toFixed(0)}% observed`} + > {(uptimeStripByGatewayId.get(gateway.gateway_id) ?? []).map((status, index) => { const tooltip = getUptimeBucketTooltip(status, index, 30, windowMinutes); + // On phones the strip spans the whole card, so more tooltips anchor to an edge const tooltipPosition = index < 4 ? 'left-0' : index > 25 ? 'right-0' - : 'left-1/2 -translate-x-1/2'; + : index < 10 + ? 'left-0 sm:left-1/2 sm:-translate-x-1/2' + : index > 19 + ? 'right-0 sm:right-auto sm:left-1/2 sm:-translate-x-1/2' + : 'left-1/2 -translate-x-1/2'; return ( - -
On {formatCompactDuration(gateway.estimatedOnlineMinutes)}Off {formatCompactDuration(gateway.estimatedOfflineMinutes)}Unknown {formatCompactDuration(gateway.estimatedUnknownMinutes)}
+ +
Activity
{gateway.realActivityScore !== null ? ( <>
- {gateway.realActivityScore.toLocaleString()} score + {gateway.realActivityScore.toLocaleString()} score
{metricsWindow} · {gateway.fundCountWindow} funds · {gateway.settleCountWindow} settles · {gateway.cancelCountWindow} cancels
@@ -812,27 +1057,27 @@ export function FederationGateways() { ) : (
- N/A (no real {metricsWindow} data) + No activity recorded in {metricsWindow}
)}
+ +
Trust & history
{gateway.vetted ? '✓ Vetted' : 'Not vetted'}
First seen
{formatDateTime(gateway.firstSeenDate)}
Last seen
{formatDateTime(gateway.lastSeenDate)}
- +
Endpoint
+ {/* Gateway APIs (iroh:// or /v1 endpoints) are not pages, so show them as text to copy */} + - {formatEndpointLabel(gateway.api_endpoint)} -
+ {gateway.api_endpoint} + {gateway.raw && ( )} + )} {rawAnnouncement && (
{ - const query = window ? `?window=${encodeURIComponent(window)}` : ''; - const response = await fetch(`${BASE_URL}/federations/${id}/gateways${query}`); - if (!response.ok) { - throw new Error(`Failed to fetch gateways for federation ${id} (${response.status})`); - } - return response.json(); + async getFederationGateways( + id: string, + window: GatewayWindow, + signal?: AbortSignal, + ): Promise> { + return fetchCached( + `${BASE_URL}/federations/${id}/gateways?window=${encodeURIComponent(window)}`, + 'Failed to fetch gateways', + signal, + ); }, async getFederationGatewayUptimeTrend( id: string, window: GatewayWindow, - ): Promise { - const response = await fetch( + signal?: AbortSignal, + ): Promise> { + return fetchCached( `${BASE_URL}/federations/${id}/gateways/uptime-trend?window=${encodeURIComponent(window)}`, + 'Failed to fetch the gateway uptime trend', + signal, ); - if (!response.ok) { - throw new Error(`Failed to fetch gateway uptime trend (${response.status})`); - } - return response.json(); }, - async getFederationGatewaysByInvite(inviteCode: string): Promise { + async getFederationGatewaysByInvite(inviteCode: string, signal?: AbortSignal): Promise { const encodedInvite = encodeURIComponent(inviteCode); - const response = await fetch(`${BASE_URL}/config/${encodedInvite}/gateways`); + const response = await fetch(`${BASE_URL}/config/${encodedInvite}/gateways`, { signal }); if (!response.ok) { throw new Error(`Failed to fetch gateways by invite (${response.status})`); } return response.json(); }, }; + +export interface CachedResponse { + data: T; + // Nginx answered from an expired cache entry and is refreshing it in the background + stale: boolean; +} + +async function fetchCached(url: string, errorMessage: string, signal?: AbortSignal): Promise> { + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`${errorMessage} (${response.status})`); + } + // Only readable on the same origin, which is how the site is served + const cacheStatus = response.headers.get('X-Cache-Status'); + return { data: await response.json(), stale: cacheStatus === 'STALE' || cacheStatus === 'UPDATING' }; +} diff --git a/fmo_frontend_react/src/utils/storage.ts b/fmo_frontend_react/src/utils/storage.ts new file mode 100644 index 0000000..d7602d2 --- /dev/null +++ b/fmo_frontend_react/src/utils/storage.ts @@ -0,0 +1,22 @@ +// Browsers throw on any localStorage access when site data is blocked, so +// preferences must never be able to break a page. + +export function readStorage(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +export function writeStorage(key: string, value: string | null): void { + try { + if (value === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, value); + } + } catch { + // The preference is not remembered; nothing else depends on it + } +} From ec4c04ab7f264cbd982777e34bb7bee23b1fd7d6 Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Fri, 9 Oct 2026 21:31:44 +0530 Subject: [PATCH 2/7] fix(frontend): stop the dark flash on load and gateways flipping to degraded - Apply the saved or system theme in index.html before the first paint; the page shipped with class="dark" and only corrected it after React started, so light-theme users saw a dark flash on every load. - Count a gateway as online for up to 15 minutes since it was last seen. With 5-minute polls and answers cached for up to 6 minutes, the old 10-minute limit marked healthy gateways degraded after one late poll. Co-Authored-By: Claude Opus 5.5 --- fmo_frontend_react/index.html | 11 ++++++++++- fmo_frontend_react/src/pages/FederationGateways.tsx | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/fmo_frontend_react/index.html b/fmo_frontend_react/index.html index 2788dd0..881c2dd 100644 --- a/fmo_frontend_react/index.html +++ b/fmo_frontend_react/index.html @@ -1,6 +1,15 @@ - + + diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx index 8199cd1..1b22dae 100644 --- a/fmo_frontend_react/src/pages/FederationGateways.tsx +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -75,7 +75,9 @@ function parseTimestamp(value?: string): Date | null { function getGatewayStatus(lastSeen: Date | null, now: number): GatewayStatus { if (!lastSeen) return 'unknown'; const minutes = (now - lastSeen.getTime()) / (1000 * 60); - if (minutes <= 10) return 'online'; + // Gateways are polled every 5 minutes and answers are cached for up to 6, so allow + // up to three polls before a healthy gateway stops counting as online + if (minutes <= 15) return 'online'; if (minutes <= 30) return 'degraded'; return 'offline'; } From 702fff5a7a087186572badcc6707f2e4da2bb43d Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Fri, 9 Oct 2026 21:36:43 +0530 Subject: [PATCH 3/7] perf(gateways): make long windows fast and stop retired gateways dragging uptime - Mark the activity CTEs NOT MATERIALIZED. Materialized, the planner estimated one row for each and nested-looped ~10k x 10k rows: the 90D query for Global Bitcoin Federation took 55 s, now 4 s, same results. - Treat a gateway missing from the registry for 7 days as retired: stop writing "not seen" samples for it, and leave samples taken after that out of per-gateway uptime and the trend. Previously every gateway ever seen counted as 0% forever (E-Cash Club showed 28.6% uptime while both active gateways were at 100%). Co-Authored-By: Claude Opus 5.5 --- fmo_server/src/federation/gateways.rs | 63 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs index 03e989f..517f707 100644 --- a/fmo_server/src/federation/gateways.rs +++ b/fmo_server/src/federation/gateways.rs @@ -25,6 +25,10 @@ use crate::util::query; const GATEWAY_POLL_INTERVAL_MINUTES: u64 = 5; const GATEWAY_SNAPSHOT_RETENTION_DAYS: i64 = 90; const GATEWAY_PRUNE_INTERVAL_HOURS: i64 = 6; +// A gateway missing from the registry this long has left the federation: it +// gets no more "not seen" samples, and later ones don't count against uptime or +// the trend +const GATEWAY_RETIRED_AFTER_DAYS: i32 = 7; #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum GatewayMetricsWindow { @@ -324,6 +328,7 @@ impl FederationObserver { SELECT g.gateway_id, FALSE AS is_seen FROM gateways g WHERE g.federation_id = $1 + AND g.last_seen >= $2 - make_interval(days => $4) AND NOT EXISTS ( SELECT 1 FROM current_gateway_ids c @@ -339,7 +344,12 @@ impl FederationObserver { a.is_seen FROM all_gateway_ids a ON CONFLICT DO NOTHING", - &[&federation_id_bytes, &now, &gateway_ids], + &[ + &federation_id_bytes, + &now, + &gateway_ids, + &GATEWAY_RETIRED_AFTER_DAYS, + ], ) .await?; @@ -416,9 +426,12 @@ impl FederationObserver { ) .await?; + // NOT MATERIALIZED lets the planner see real row counts through the CTEs; + // materialized, it estimated one row each and nested-looped ~10k x 10k + // rows (90D: 55 s instead of 4 s) let activity_rows = query::( &conn, - "WITH tx_window AS ( + "WITH tx_window AS NOT MATERIALIZED ( SELECT t.federation_id, t.txid FROM transactions t JOIN session_times st @@ -427,7 +440,7 @@ impl FederationObserver { WHERE t.federation_id = $1 AND st.estimated_session_timestamp >= $2 ), - window_ln_outputs AS ( + window_ln_outputs AS NOT MATERIALIZED ( SELECT o.federation_id, o.txid, @@ -443,7 +456,7 @@ impl FederationObserver { AND o.kind = 'ln' AND o.ln_contract_id IS NOT NULL ), - window_ln_inputs AS ( + window_ln_inputs AS NOT MATERIALIZED ( SELECT i.federation_id, i.txid, @@ -456,7 +469,7 @@ impl FederationObserver { AND i.kind = 'ln' AND i.ln_contract_id IS NOT NULL ), - contract_map AS ( + contract_map AS NOT MATERIALIZED ( SELECT DISTINCT ON (wlo.federation_id, wlo.ln_contract_id) wlo.federation_id, wlo.ln_contract_id, @@ -539,14 +552,20 @@ impl FederationObserver { let uptime_rows = query::( &conn, "SELECT - gateway_id, - COUNT(*) FILTER (WHERE is_seen)::bigint AS seen_samples, + s.gateway_id, + COUNT(*) FILTER (WHERE s.is_seen)::bigint AS seen_samples, COUNT(*)::bigint AS total_samples - FROM gateway_poll_snapshots - WHERE federation_id = $1 - AND poll_time >= $2 - GROUP BY gateway_id", - &[&federation_id_bytes, &window_start_utc], + FROM gateway_poll_snapshots s + JOIN gateways g ON g.federation_id = s.federation_id AND g.gateway_id = s.gateway_id + WHERE s.federation_id = $1 + AND s.poll_time >= $2 + AND s.poll_time <= g.last_seen + make_interval(days => $3) + GROUP BY s.gateway_id", + &[ + &federation_id_bytes, + &window_start_utc, + &GATEWAY_RETIRED_AFTER_DAYS, + ], ) .await?; @@ -627,15 +646,21 @@ impl FederationObserver { let rows = query::( &conn, "SELECT - date_trunc('day', poll_time) AS day, - COUNT(*) FILTER (WHERE is_seen)::bigint AS seen_samples, + date_trunc('day', s.poll_time) AS day, + COUNT(*) FILTER (WHERE s.is_seen)::bigint AS seen_samples, COUNT(*)::bigint AS total_samples - FROM gateway_poll_snapshots - WHERE federation_id = $1 - AND poll_time >= $2 - GROUP BY date_trunc('day', poll_time) + FROM gateway_poll_snapshots s + JOIN gateways g ON g.federation_id = s.federation_id AND g.gateway_id = s.gateway_id + WHERE s.federation_id = $1 + AND s.poll_time >= $2 + AND s.poll_time <= g.last_seen + make_interval(days => $3) + GROUP BY date_trunc('day', s.poll_time) ORDER BY day ASC", - &[&federation_id_bytes, &window_start], + &[ + &federation_id_bytes, + &window_start, + &GATEWAY_RETIRED_AFTER_DAYS, + ], ) .await?; From 5fe9b4e5de2cdf8262c8f057cbf5d908fad9ea01 Mon Sep 17 00:00:00 2001 From: Ayush Bansal Date: Fri, 9 Oct 2026 21:41:16 +0530 Subject: [PATCH 4/7] feat(gateways): list retired gateways separately A gateway last seen more than 7 days ago has left the federation (the same rule the backend now uses). Show it as "retired" instead of "offline", hide it from the default list behind its own Retired filter, and leave it out of the header counts, uptime and observed figures. Co-Authored-By: Claude Opus 5.5 --- .../src/pages/FederationGateways.tsx | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx index 1b22dae..3ac16ff 100644 --- a/fmo_frontend_react/src/pages/FederationGateways.tsx +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -12,13 +12,14 @@ import { readStorage, writeStorage } from '../utils/storage'; import { useTheme } from '../hooks/useTheme'; import type { EChartsOption } from 'echarts'; -type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown'; +type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'retired'; type UptimeStripStatus = 'online' | 'degraded' | 'offline' | 'unknown'; // The backend also accepts 1h, which is too short for its 5-minute polls const GATEWAY_WINDOWS = ['24h', '7d', '30d', '90d'] as const; type SelectableWindow = (typeof GATEWAY_WINDOWS)[number]; -const GATEWAY_FILTERS = ['all', 'online', 'degraded', 'offline', 'unknown'] as const; +// 'all' means every active gateway; retired ones have their own filter +const GATEWAY_FILTERS = ['all', 'online', 'degraded', 'offline', 'unknown', 'retired'] as const; const GATEWAY_SORTS = ['freshness', 'status', 'uptime', 'activity'] as const; type GatewaySort = (typeof GATEWAY_SORTS)[number]; const SORT_DIRECTIONS = ['asc', 'desc'] as const; @@ -41,11 +42,13 @@ const STALE_RETRY_MS = 2_500; // doubled after each stale answer const MAX_STALE_RETRIES = 4; const LIVE_LOOKUP_TIMEOUT_MS = 10_000; const EMPTY_TREND: GatewayUptimeTrendPoint[] = []; +const RETIRED_AFTER_MINUTES = 7 * 24 * 60; const STATUS_RANK: Record = { offline: 0, degraded: 1, unknown: 2, online: 3, + retired: 4, }; interface GatewayWithStatus extends GatewayInfo { @@ -79,6 +82,8 @@ function getGatewayStatus(lastSeen: Date | null, now: number): GatewayStatus { // up to three polls before a healthy gateway stops counting as online if (minutes <= 15) return 'online'; if (minutes <= 30) return 'degraded'; + // Gone from the registry for a week: it has left the federation (same rule as the backend) + if (minutes > RETIRED_AFTER_MINUTES) return 'retired'; return 'offline'; } @@ -156,7 +161,7 @@ function getUptimeStripClass(status: UptimeStripStatus): string { function buildUptimeStrip(gateway: GatewayWithStatus, windowMinutes: number): UptimeStripStatus[] { const segments = 30; - if (gateway.status === 'unknown' || windowMinutes <= 0) { + if (gateway.status === 'unknown' || gateway.status === 'retired' || windowMinutes <= 0) { return Array.from({ length: segments }, () => 'unknown'); } @@ -753,34 +758,38 @@ export function FederationGateways() { }); }, [gateways, now, windowMinutes]); + // Retired gateways are listed separately and left out of every figure + const activeRows = useMemo(() => rows.filter((row) => row.status !== 'retired'), [rows]); + const totals = useMemo(() => { - const total = rows.length; - const online = rows.filter((row) => row.status === 'online').length; - const degraded = rows.filter((row) => row.status === 'degraded').length; - const offline = rows.filter((row) => row.status === 'offline').length; + const total = activeRows.length; + const online = activeRows.filter((row) => row.status === 'online').length; + const degraded = activeRows.filter((row) => row.status === 'degraded').length; + const offline = activeRows.filter((row) => row.status === 'offline').length; const unknown = total - online - degraded - offline; - const vetted = rows.filter((row) => row.vetted).length; + const vetted = activeRows.filter((row) => row.vetted).length; + const retired = rows.length - total; - return { total, online, degraded, offline, unknown, vetted }; - }, [rows]); + return { total, online, degraded, offline, unknown, vetted, retired }; + }, [activeRows, rows]); const avgUptime = useMemo(() => { - const observedRows = rows.filter((row) => row.coveragePct > 0); + const observedRows = activeRows.filter((row) => row.coveragePct > 0); if (observedRows.length === 0) return 0; const total = observedRows.reduce((sum, row) => sum + row.estimatedUptimePct, 0); return total / observedRows.length; - }, [rows]); + }, [activeRows]); const avgCoverage = useMemo(() => { - if (rows.length === 0) return 0; - const total = rows.reduce((sum, row) => sum + row.coveragePct, 0); - return total / rows.length; - }, [rows]); + if (activeRows.length === 0) return 0; + const total = activeRows.reduce((sum, row) => sum + row.coveragePct, 0); + return total / activeRows.length; + }, [activeRows]); const filteredRows = useMemo(() => { const query = gatewaySearch.trim().toLowerCase(); const filtered = rows.filter((row) => ( - (gatewayFilter === 'all' || row.status === gatewayFilter) + (gatewayFilter === 'all' ? row.status !== 'retired' : row.status === gatewayFilter) && (!query || row.searchText.includes(query)) )); @@ -913,7 +922,7 @@ export function FederationGateways() {
{hasData && ( -
{totals.total} gateways● {totals.online} online● {totals.degraded} degraded● {totals.offline} offline{totals.unknown > 0 && ● {totals.unknown} unknown}{totals.vetted} vetted{avgCoverage > 0 ? <>{avgUptime.toFixed(1)}% uptime ({avgCoverage.toFixed(0)}% observed) : 'No uptime data'}
+
{totals.total} gateways● {totals.online} online● {totals.degraded} degraded● {totals.offline} offline{totals.unknown > 0 && ● {totals.unknown} unknown}{totals.retired > 0 && {totals.retired} retired}{totals.vetted} vetted{avgCoverage > 0 ? <>{avgUptime.toFixed(1)}% uptime ({avgCoverage.toFixed(0)}% observed) : 'No uptime data'}
)} @@ -946,7 +955,7 @@ export function FederationGateways() {

Gateway directory

{filteredRows.length} of {rows.length} gateways · every row includes its 30-bucket availability strip, ordered oldest to newest.

setGatewaySearch(event.target.value)} aria-label="Search gateways" placeholder="Search gateway, node, endpoint" className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />
-
{GATEWAY_FILTERS.map((filter) => )}
+
{GATEWAY_FILTERS.map((filter) => )}
OnlineDegradedOfflineUnknown
{/* Phones get one stacked card per gateway instead of a 960px table behind a sideways scroll */} @@ -972,7 +981,11 @@ export function FederationGateways() { {filteredRows.length === 0 && (
- {rows.length === 0 ? 'No gateways to show.' : 'No gateways match these filters.'} + {gatewayFilter !== 'all' || gatewaySearch.trim() + ? 'No gateways match these filters.' + : totals.retired > 0 + ? `No active gateways. ${totals.retired} retired ${totals.retired === 1 ? 'one is' : 'ones are'} under the Retired filter.` + : 'No gateways to show.'}