From 533b3dc4dd85f6d346e5ff468395ff2cbef04568 Mon Sep 17 00:00:00 2001 From: bansalayush247 Date: Sat, 14 Mar 2026 15:23:49 +0530 Subject: [PATCH 1/4] feat: add federation gateway monitoring and API --- Cargo.lock | 1 + fmo_api_types/Cargo.toml | 1 + fmo_api_types/src/lib.rs | 17 +++ fmo_server/schema/v9.sql | 24 +++ fmo_server/src/federation/gateways.rs | 203 ++++++++++++++++++++++++++ fmo_server/src/federation/mod.rs | 3 + fmo_server/src/federation/observer.rs | 21 ++- 7 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 fmo_server/schema/v9.sql create mode 100644 fmo_server/src/federation/gateways.rs diff --git a/Cargo.lock b/Cargo.lock index 7f648f9..af4321f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,6 +2236,7 @@ dependencies = [ "bitcoin 0.32.6", "fedimint-core 0.8.0-beta.2", "serde", + "serde_json", ] [[package]] diff --git a/fmo_api_types/Cargo.toml b/fmo_api_types/Cargo.toml index 934885f..4c2249a 100644 --- a/fmo_api_types/Cargo.toml +++ b/fmo_api_types/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" bitcoin = { version = "0.32.5", features = ["serde"] } fedimint-core = { workspace = true } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/fmo_api_types/src/lib.rs b/fmo_api_types/src/lib.rs index 8ed251c..30586d8 100644 --- a/fmo_api_types/src/lib.rs +++ b/fmo_api_types/src/lib.rs @@ -62,3 +62,20 @@ pub enum FederationHealth { Degraded, Offline, } + +/// Subset of a gateway's registration info suitable for public API responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayInfo { + /// Gateway's public key (hex-encoded) + pub gateway_id: String, + /// LN node public key (hex-encoded) + pub node_pub_key: String, + pub lightning_alias: String, + /// URL of the gateway's public API + pub api_endpoint: String, + /// Whether the federation has vetted this gateway + pub vetted: bool, + /// Full raw announcement, useful for forwards-compatible client usage + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, +} diff --git a/fmo_server/schema/v9.sql b/fmo_server/schema/v9.sql new file mode 100644 index 0000000..b262f74 --- /dev/null +++ b/fmo_server/schema/v9.sql @@ -0,0 +1,24 @@ +BEGIN; + +INSERT INTO + schema_version (version) +VALUES + (9); + +CREATE TABLE IF NOT EXISTS gateways ( + federation_id BYTEA NOT NULL REFERENCES federations (federation_id), + gateway_id TEXT NOT NULL, + node_pub_key TEXT NOT NULL, + api_endpoint TEXT NOT NULL, + lightning_alias TEXT NOT NULL, + vetted BOOLEAN NOT NULL DEFAULT FALSE, + raw JSONB NOT NULL, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (federation_id, gateway_id) +); + +CREATE INDEX IF NOT EXISTS gateways_federation_id ON gateways (federation_id); +CREATE INDEX IF NOT EXISTS gateways_node_pub_key ON gateways (node_pub_key); + +COMMIT; diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs new file mode 100644 index 0000000..e611f77 --- /dev/null +++ b/fmo_server/src/federation/gateways.rs @@ -0,0 +1,203 @@ +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Context; +use axum::extract::{Path, State}; +use axum::Json; +use fedimint_api_client::api::{DynGlobalApi, FederationApiExt}; +use fedimint_core::config::{ClientConfig, FederationId}; +use fedimint_core::core::ModuleInstanceId; +use fedimint_core::encoding::Encodable; +use fedimint_core::module::ApiRequestErased; +use fedimint_ln_common::federation_endpoint_constants::LIST_GATEWAYS_ENDPOINT; +use fedimint_ln_common::LightningGatewayAnnouncement; +use fmo_api_types::GatewayInfo; +use tracing::{info, warn}; + +use crate::federation::observer::FederationObserver; +use crate::util::query; + +impl FederationObserver { + /// Background task: poll the LN module on this federation for currently + /// registered gateways and persist them. Runs in a loop until cancelled. + pub async fn monitor_gateways( + &self, + federation_id: FederationId, + config: ClientConfig, + ) -> anyhow::Result<()> { + const POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); + + let api = DynGlobalApi::from_endpoints( + config + .global + .api_endpoints + .iter() + .map(|(&peer_id, peer_url)| (peer_id, peer_url.url.clone())), + &None, + ) + .await?; + + let ln_instance_id = config + .modules + .iter() + .find_map(|(&instance_id, module)| { + (module.kind.as_str() == "ln").then_some(instance_id) + }) + .context("No LN module found in federation config")?; + + let peer_ids: Vec = + config.global.api_endpoints.keys().copied().collect(); + + let mut interval = tokio::time::interval(POLL_INTERVAL); + loop { + interval.tick().await; + if let Err(e) = Self::fetch_and_store_gateways( + self, + federation_id, + &api, + ln_instance_id, + &peer_ids, + ) + .await + { + warn!( + "Failed to fetch gateways for federation {}: {:?}", + federation_id, e + ); + } + } + } + + async fn fetch_and_store_gateways( + &self, + federation_id: FederationId, + api: &DynGlobalApi, + ln_instance_id: ModuleInstanceId, + peer_ids: &[fedimint_core::PeerId], + ) -> anyhow::Result<()> { + // Query all peers and merge by gateway_id — each guardian has their own + // registry so we take the union across all peers. + let mut merged: HashMap = HashMap::new(); + + for &peer_id in peer_ids { + let result: anyhow::Result> = api + .with_module(ln_instance_id) + .request_single_peer( + LIST_GATEWAYS_ENDPOINT.to_owned(), + ApiRequestErased::default(), + peer_id, + ) + .await + .map_err(anyhow::Error::from) + .and_then(|v| serde_json::from_value(v).map_err(anyhow::Error::from)); + + match result { + Ok(gateways) => { + for gw in gateways { + merged + .entry(gw.info.gateway_id.to_string()) + .or_insert(gw); + } + } + Err(e) => { + warn!( + "Failed to fetch gateways from peer {} for {}: {:?}", + peer_id, federation_id, e + ); + } + } + } + + let conn = self.connection().await?; + let now = chrono::Utc::now(); + let federation_id_bytes = federation_id.consensus_encode_to_vec(); + + for (gateway_id, gw) in &merged { + let node_pub_key = gw.info.node_pub_key.to_string(); + let api_endpoint = gw.info.api.to_string(); + let lightning_alias = &gw.info.lightning_alias; + let vetted = gw.vetted; + let raw = serde_json::to_value(gw)?; + + conn.execute( + "INSERT INTO gateways + (federation_id, gateway_id, node_pub_key, api_endpoint, + lightning_alias, vetted, raw, first_seen, last_seen) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT (federation_id, gateway_id) DO UPDATE + SET node_pub_key = EXCLUDED.node_pub_key, + api_endpoint = EXCLUDED.api_endpoint, + lightning_alias = EXCLUDED.lightning_alias, + vetted = EXCLUDED.vetted, + raw = EXCLUDED.raw, + last_seen = EXCLUDED.last_seen", + &[ + &federation_id_bytes, + gateway_id, + &node_pub_key, + &api_endpoint, + lightning_alias, + &vetted, + &raw, + &now, + ], + ) + .await?; + } + + info!( + "Stored {} gateway(s) for federation {}", + merged.len(), + federation_id + ); + Ok(()) + } + + pub async fn list_federation_gateways( + &self, + federation_id: FederationId, + ) -> anyhow::Result> { + #[derive(postgres_from_row::FromRow)] + struct GatewayRow { + gateway_id: String, + node_pub_key: String, + lightning_alias: String, + api_endpoint: String, + vetted: bool, + raw: serde_json::Value, + } + + let rows = query::( + &self.connection().await?, + "SELECT gateway_id, node_pub_key, lightning_alias, api_endpoint, vetted, raw + FROM gateways + WHERE federation_id = $1 + ORDER BY last_seen DESC", + &[&federation_id.consensus_encode_to_vec()], + ) + .await?; + + Ok(rows + .into_iter() + .map(|r| GatewayInfo { + gateway_id: r.gateway_id, + node_pub_key: r.node_pub_key, + lightning_alias: r.lightning_alias, + api_endpoint: r.api_endpoint, + vetted: r.vetted, + raw: Some(r.raw), + }) + .collect()) + } +} + +pub(super) async fn get_federation_gateways( + Path(federation_id): Path, + State(state): State, +) -> crate::error::Result>> { + Ok(state + .federation_observer + .list_federation_gateways(federation_id) + .await? + .into()) +} diff --git a/fmo_server/src/federation/mod.rs b/fmo_server/src/federation/mod.rs index 2fd8b64..e4580e7 100644 --- a/fmo_server/src/federation/mod.rs +++ b/fmo_server/src/federation/mod.rs @@ -1,4 +1,5 @@ pub mod db; +mod gateways; mod guardians; mod meta; pub(crate) mod nostr; @@ -19,6 +20,7 @@ use fmo_api_types::{FederationSummary, FedimintTotals}; use serde::Deserialize; use serde_json::json; +use crate::federation::gateways::get_federation_gateways; use crate::federation::guardians::get_federation_health; use crate::federation::meta::get_federation_meta; use crate::federation::session::{count_sessions, list_sessions}; @@ -55,6 +57,7 @@ pub fn get_federations_routes() -> Router { "/:federation_id/transactions/histogram", get(transaction_histogram), ) + .route("/:federation_id/gateways", get(get_federation_gateways)) .route("/:federation_id/utxos", get(get_federation_utxos)) .route("/:federation_id/sessions", get(list_sessions)) .route("/:federation_id/sessions/count", get(count_sessions)) diff --git a/fmo_server/src/federation/observer.rs b/fmo_server/src/federation/observer.rs index 1c0715d..b716faf 100644 --- a/fmo_server/src/federation/observer.rs +++ b/fmo_server/src/federation/observer.rs @@ -113,7 +113,7 @@ impl FederationObserver { let slf = self.clone(); let federation_id = federation.federation_id; - let config = federation.config; + let config = federation.config.clone(); self.task_group.spawn_cancellable( format!("Health Monitor for {}", federation_id), async move { @@ -128,6 +128,24 @@ impl FederationObserver { } .instrument(info_span!("health", fed = %federation_id.to_prefix())), ); + + let slf = self.clone(); + let federation_id = federation.federation_id; + let config = federation.config; + self.task_group.spawn_cancellable( + format!("Gateway Monitor for {}", federation_id), + async move { + loop { + let e = slf + .monitor_gateways(federation_id, config.clone()) + .await + .expect_err("gateway monitor task exited unexpectedly"); + error!("Gateway Monitor errored, restarting in 30s: {e}"); + tokio::time::sleep(Duration::from_secs(30)).await; + } + } + .instrument(info_span!("gateways", fed = %federation_id.to_prefix())), + ); } async fn setup_schema(&self) -> anyhow::Result<()> { @@ -190,6 +208,7 @@ impl FederationObserver { "/schema/v8.sql", FederationObserver::backfill_reprocess_all_sessions ), + migration!("/schema/v9.sql"), ]; for (index, migration) in migrations.iter().enumerate() { From a4096410c08b49049aca3c13a5a3262d821f87f7 Mon Sep 17 00:00:00 2001 From: bansalayush247 Date: Sat, 14 Mar 2026 21:52:41 +0530 Subject: [PATCH 2/4] fix: resolve CI lint and clippy failures --- fmo_api_types/src/lib.rs | 2 + .../components/federation/stars_selector.rs | 50 +++++++++++++++++++ fmo_server/src/federation/gateways.rs | 15 ++---- justfile | 2 +- 4 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 fmo_frontend/src/components/federation/stars_selector.rs diff --git a/fmo_api_types/src/lib.rs b/fmo_api_types/src/lib.rs index 1acaede..087a50d 100644 --- a/fmo_api_types/src/lib.rs +++ b/fmo_api_types/src/lib.rs @@ -78,6 +78,8 @@ pub struct GatewayInfo { /// Full raw announcement, useful for forwards-compatible client usage #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NoncesRequest { pub nonces: Vec, diff --git a/fmo_frontend/src/components/federation/stars_selector.rs b/fmo_frontend/src/components/federation/stars_selector.rs new file mode 100644 index 0000000..711f481 --- /dev/null +++ b/fmo_frontend/src/components/federation/stars_selector.rs @@ -0,0 +1,50 @@ +use leptos::prelude::*; + +#[component] +pub fn StarsSelector(default_value: u8, selected_stars: WriteSignal) -> impl IntoView { + selected_stars.set(default_value); + + let (selected, set_selected) = signal(default_value); + let (hover, set_hover) = signal(None); + + let star = move |star_idx: u8, fill: bool, border: bool| { + view! { + + } + }; + + view! { +
+ { move || { + let selected = selected.get(); + let hover = hover.get(); + (1..=5).map(|idx| star(idx, selected >= idx, hover.is_some_and(|h| h >= idx))).collect::>() + }} +
+ } +} diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs index e611f77..ac157c0 100644 --- a/fmo_server/src/federation/gateways.rs +++ b/fmo_server/src/federation/gateways.rs @@ -51,14 +51,9 @@ impl FederationObserver { let mut interval = tokio::time::interval(POLL_INTERVAL); loop { interval.tick().await; - if let Err(e) = Self::fetch_and_store_gateways( - self, - federation_id, - &api, - ln_instance_id, - &peer_ids, - ) - .await + if let Err(e) = + Self::fetch_and_store_gateways(self, federation_id, &api, ln_instance_id, &peer_ids) + .await { warn!( "Failed to fetch gateways for federation {}: {:?}", @@ -94,9 +89,7 @@ impl FederationObserver { match result { Ok(gateways) => { for gw in gateways { - merged - .entry(gw.info.gateway_id.to_string()) - .or_insert(gw); + merged.entry(gw.info.gateway_id.to_string()).or_insert(gw); } } Err(e) => { diff --git a/justfile b/justfile index b26197b..059d0ad 100644 --- a/justfile +++ b/justfile @@ -96,7 +96,7 @@ clippy_package PACKAGE *ARGS="--locked": clippy *ARGS="--locked": just clippy_package fmo_server {{ARGS}} - RUSTFLAGS="$RUSTFLAGS --cfg getrandom_backend=\"wasm_js\"" just clippy_package fmo_frontend --target wasm32-unknown-unknown {{ARGS}} + RUSTFLAGS="${RUSTFLAGS:-} --cfg getrandom_backend=\"wasm_js\"" just clippy_package fmo_frontend --target wasm32-unknown-unknown {{ARGS}} # run `cargo clippy --fix` on everything clippy_fix-package PACKAGE *ARGS="--locked --offline": From a04a36cb690abee52f946106d0c672e4617fa2ae Mon Sep 17 00:00:00 2001 From: bansalayush247 Date: Sun, 29 Mar 2026 01:42:42 +0530 Subject: [PATCH 3/4] feat(api): add endpoints for fetching federation gateways - Introduced and methods in the API service to retrieve gateway information based on federation ID and invite code. - Updated API types to include , , and for better structure and type safety. feat(database): create gateway_poll_snapshots table - Added a new table to store snapshots of gateway visibility over time, including fields for federation ID, gateway ID, poll time, and visibility status. feat(federation): implement gateway fetching logic - Implemented to aggregate gateway information from federation peers. - Enhanced the to periodically poll and store gateway data, including metrics for activity and uptime. fix(federation): update gateway listing to include metrics - Modified to return additional metrics such as activity and uptime for specified time windows. - Improved error handling and logging for gateway fetching operations. refactor(federation): adjust module visibility - Changed the visibility of the module to to restrict access to internal components only. --- fmo_api_types/src/lib.rs | 38 + fmo_frontend_react/src/App.tsx | 2 + .../src/pages/FederationDetail.tsx | 10 + .../src/pages/FederationGateways.tsx | 819 ++++++++++++++++++ fmo_frontend_react/src/services/api.ts | 25 +- fmo_frontend_react/src/types/api.ts | 32 + fmo_server/schema/v9.sql | 11 + fmo_server/src/config/mod.rs | 13 + fmo_server/src/federation/gateways.rs | 497 ++++++++++- fmo_server/src/federation/mod.rs | 2 +- 10 files changed, 1412 insertions(+), 37 deletions(-) create mode 100644 fmo_frontend_react/src/pages/FederationGateways.tsx diff --git a/fmo_api_types/src/lib.rs b/fmo_api_types/src/lib.rs index 087a50d..ae2a50d 100644 --- a/fmo_api_types/src/lib.rs +++ b/fmo_api_types/src/lib.rs @@ -1,4 +1,5 @@ use bitcoin::address::NetworkUnchecked; +use chrono::{DateTime, Utc}; use fedimint_core::config::FederationId; use fedimint_core::Amount; use serde::{Deserialize, Serialize}; @@ -78,6 +79,43 @@ pub struct GatewayInfo { /// Full raw announcement, useful for forwards-compatible client usage #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, + /// First time this gateway was seen by the observer + #[serde(skip_serializing_if = "Option::is_none")] + pub first_seen: Option>, + /// Most recent time this gateway was seen by the observer + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen: Option>, + /// Real LN activity metrics over the last 7 days + #[serde(skip_serializing_if = "Option::is_none")] + pub activity_7d: Option, + /// Real LN activity metrics over the requested API window + #[serde(skip_serializing_if = "Option::is_none")] + pub activity_window: Option, + /// Uptime metrics computed from periodic gateway snapshots over the + /// requested window + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_window: Option, + /// The window label used for `activity_window` and `uptime_window`, e.g. + /// `7d` + #[serde(skip_serializing_if = "Option::is_none")] + pub metrics_window: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayActivityMetrics { + pub fund_count: u64, + pub settle_count: u64, + pub cancel_count: u64, + pub total_volume_msat: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayUptimeMetrics { + pub sample_count: u64, + pub seen_samples: u64, + pub online_minutes: u64, + pub offline_minutes: u64, + pub uptime_pct: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/fmo_frontend_react/src/App.tsx b/fmo_frontend_react/src/App.tsx index de29c8c..2ebd4a9 100644 --- a/fmo_frontend_react/src/App.tsx +++ b/fmo_frontend_react/src/App.tsx @@ -3,6 +3,7 @@ import { NavBar } from './components/NavBar'; 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'; function App() { @@ -16,6 +17,7 @@ function App() { } /> } /> } /> + } /> Page not found} /> diff --git a/fmo_frontend_react/src/pages/FederationDetail.tsx b/fmo_frontend_react/src/pages/FederationDetail.tsx index 6ce2763..ec880b4 100644 --- a/fmo_frontend_react/src/pages/FederationDetail.tsx +++ b/fmo_frontend_react/src/pages/FederationDetail.tsx @@ -438,6 +438,16 @@ export function FederationDetail() { {config?.confirmations_required || 'N/A'} + {id && ( +
+ + Gateway Details + +
+ )} {federation?.invite && hasOnlineGuardian && (
Invite Link
diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx new file mode 100644 index 0000000..f872bc3 --- /dev/null +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -0,0 +1,819 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../services/api'; +import type { FederationSummary, GatewayInfo, GatewayWindow } from '../types/api'; +import { Alert } from '../components/Alert'; + +type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown'; +type UptimeStripStatus = 'online' | 'degraded' | 'offline' | 'unknown'; + +interface GatewayWithStatus extends GatewayInfo { + firstSeenDate: Date | null; + lastSeenDate: Date | null; + status: GatewayStatus; + minutesSinceLastSeen: number | null; + inferredOfflineMinutes: number; + estimatedOfflineMinutes: number; + estimatedOnlineMinutes: number; + estimatedUnknownMinutes: number; + estimatedUptimePct: number; + coveragePct: number; + realActivityScore: number | null; + fundCountWindow: number; + settleCountWindow: number; + cancelCountWindow: number; + totalVolumeMsatWindow: number; +} + +function parseTimestamp(value?: string): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function getGatewayStatus(lastSeen: Date | null): GatewayStatus { + if (!lastSeen) return 'unknown'; + const minutes = (Date.now() - lastSeen.getTime()) / (1000 * 60); + if (minutes <= 10) return 'online'; + if (minutes <= 30) return 'degraded'; + return 'offline'; +} + +function formatDateTime(date: Date | null): string { + if (!date) return 'N/A'; + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatRelative(date: Date | null): string { + if (!date) return 'Never seen'; + + const diffMs = Date.now() - date.getTime(); + const minutes = Math.floor(diffMs / (1000 * 60)); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function shortId(value: string): string { + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-8)}`; +} + +function formatDuration(minutes: number): string { + const safe = Math.max(0, Math.floor(minutes)); + const days = Math.floor(safe / (60 * 24)); + const hours = Math.floor((safe % (60 * 24)) / 60); + const mins = safe % 60; + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function formatCompactDuration(minutes: number): string { + const safe = Math.max(0, Math.round(minutes)); + if (safe >= 60 * 24) return `${Math.round(safe / (60 * 24))}d`; + if (safe >= 60) return `${Math.round(safe / 60)}h`; + if (safe === 0) return '0m'; + return `${safe}m`; +} + +function formatMsats(msat: number): string { + const sats = msat / 1000; + if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`; + if (sats >= 100_000) return `${(sats / 100_000).toFixed(1)}M sats`; + if (sats >= 1_000) return `${(sats / 1000).toFixed(1)}k sats`; + return `${Math.round(sats).toLocaleString()} sats`; +} + +function statusClasses(status: GatewayStatus): string { + switch (status) { + case 'online': + return 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'; + case 'degraded': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300'; + case 'offline': + return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'; + default: + return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + } +} + +function getUptimeStripClass(status: UptimeStripStatus): string { + switch (status) { + case 'online': + return 'bg-green-500 dark:bg-green-400'; + case 'degraded': + return 'bg-yellow-500 dark:bg-yellow-400'; + case 'offline': + return 'bg-red-500 dark:bg-red-400'; + default: + return 'bg-gray-300 dark:bg-gray-600'; + } +} + +function buildUptimeStrip(gateway: GatewayWithStatus, windowMinutes: number): UptimeStripStatus[] { + const segments = 30; + + if (gateway.status === 'unknown' || windowMinutes <= 0) { + return Array.from({ length: segments }, () => 'unknown'); + } + + const strip: UptimeStripStatus[] = Array.from({ length: segments }, () => 'unknown'); + const unknownMinutes = Math.max(0, Math.min(windowMinutes, gateway.estimatedUnknownMinutes)); + const unknownSegments = Math.max( + 0, + Math.min(segments, Math.round((unknownMinutes / Math.max(1, windowMinutes)) * segments)), + ); + const observedSegments = Math.max(0, segments - unknownSegments); + const observedStart = unknownSegments; + + for (let idx = observedStart; idx < segments; idx += 1) { + strip[idx] = 'online'; + } + + if (observedSegments === 0) { + return strip; + } + + const sampledMinutes = Math.max( + 1, + gateway.estimatedOnlineMinutes + gateway.estimatedOfflineMinutes, + ); + const offlineMinutes = Math.max(0, gateway.estimatedOfflineMinutes); + const offlineSegments = Math.max( + 0, + Math.min(observedSegments, Math.round((offlineMinutes / sampledMinutes) * observedSegments)), + ); + const offlineStatus: UptimeStripStatus = gateway.status === 'degraded' ? 'degraded' : 'offline'; + + for (let idx = segments - 1; idx >= segments - offlineSegments; idx -= 1) { + if (idx >= 0) strip[idx] = offlineStatus; + } + + if (gateway.status === 'degraded') { + strip[segments - 1] = 'degraded'; + } else if (gateway.status === 'offline') { + strip[segments - 1] = 'offline'; + } else { + strip[segments - 1] = 'online'; + } + + return strip; +} + +function getUptimeBucketLabel( + bucketIndex: number, + totalBuckets: number, + windowMinutes: number, +): string { + const minutesPerBucket = windowMinutes / totalBuckets; + const newestEndMinutes = (totalBuckets - bucketIndex) * minutesPerBucket; + const newestStartMinutes = (totalBuckets - bucketIndex - 1) * minutesPerBucket; + + const start = formatCompactDuration(newestStartMinutes); + const end = formatCompactDuration(newestEndMinutes); + return `${start}–${end} ago`; +} + +function mergeGatewayData(observedGateways: GatewayInfo[], liveGateways: GatewayInfo[]): GatewayInfo[] { + if (observedGateways.length === 0) return liveGateways; + if (liveGateways.length === 0) return observedGateways; + + const observedById = new Map(observedGateways.map((gateway) => [gateway.gateway_id, gateway] as const)); + const liveIds = new Set(liveGateways.map((gateway) => gateway.gateway_id)); + + const merged = liveGateways.map((liveGateway) => { + const observedGateway = observedById.get(liveGateway.gateway_id); + if (!observedGateway) return liveGateway; + + return { + ...liveGateway, + lightning_alias: liveGateway.lightning_alias || observedGateway.lightning_alias, + api_endpoint: liveGateway.api_endpoint || observedGateway.api_endpoint, + node_pub_key: liveGateway.node_pub_key || observedGateway.node_pub_key, + vetted: liveGateway.vetted || observedGateway.vetted, + raw: liveGateway.raw ?? observedGateway.raw, + first_seen: observedGateway.first_seen ?? liveGateway.first_seen, + last_seen: observedGateway.last_seen ?? liveGateway.last_seen, + activity_7d: observedGateway.activity_7d ?? liveGateway.activity_7d, + activity_window: observedGateway.activity_window ?? liveGateway.activity_window, + uptime_window: observedGateway.uptime_window ?? liveGateway.uptime_window, + metrics_window: observedGateway.metrics_window ?? liveGateway.metrics_window, + }; + }); + + for (const observedGateway of observedGateways) { + if (!liveIds.has(observedGateway.gateway_id)) { + merged.push(observedGateway); + } + } + + return merged; +} + +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 hasLoadedOnce = useRef(false); + const requestSeq = useRef(0); + const federationCache = useRef>(new Map()); + const liveGatewayCache = useRef>(new Map()); + + useEffect(() => { + if (!id) return; + let cancelled = false; + const currentRequest = ++requestSeq.current; + + if (!hasLoadedOnce.current) { + setLoading(true); + } else { + setWindowLoading(true); + } + setError(null); + setGatewayWarning(null); + + (async () => { + try { + 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; + + if (liveGateways.length > 0) { + if (observedGateways.length === 0) { + setGateways(liveGateways); + setGatewayWarning( + 'Showing live gateway data via invite-based lookup (observed gateway data unavailable on this API backend).', + ); + } else { + setGateways(mergeGatewayData(observedGateways, liveGateways)); + setGatewayWarning( + 'Merged observed gateway history (status/activity) with live invite-based registry (latest metadata).', + ); + } + return; + } + + if (observedGateways.length > 0) { + setGateways(observedGateways); + if (liveError) { + setGatewayWarning( + `Live invite lookup failed (${liveError}); showing observed gateway data from backend.`, + ); + } else if (fed?.invite) { + setGatewayWarning( + 'Live invite lookup returned no gateways; showing observed gateway data from backend.', + ); + } + return; + } + + const reason = observedError ?? 'No gateway data available on the configured API backend.'; + setGateways([]); + if (liveError) { + setGatewayWarning( + `Gateway data is unavailable right now. ${reason}. Live fallback also failed: ${liveError}`, + ); + } else if (fed?.invite) { + setGatewayWarning('No gateway data returned from either observed or invite-based live lookup.'); + } else { + setGatewayWarning(`Gateway data is unavailable right now. ${reason}`); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load gateways'; + if (!hasLoadedOnce.current) { + setError(message); + } else { + setGatewayWarning(`Failed to refresh selected window: ${message}`); + } + } finally { + if (!cancelled && currentRequest === requestSeq.current) { + if (!hasLoadedOnce.current) { + setLoading(false); + hasLoadedOnce.current = true; + } + setWindowLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [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; + } + }, [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 minutesSinceLastSeen = lastSeenDate + ? (Date.now() - lastSeenDate.getTime()) / (1000 * 60) + : null; + const observedUptime = gateway.uptime_window; + const hasObservedSamples = Boolean(observedUptime && observedUptime.sample_count > 0); + const rawObservedOnlineMinutes = hasObservedSamples ? (observedUptime?.online_minutes ?? 0) : 0; + const rawObservedOfflineMinutes = hasObservedSamples ? (observedUptime?.offline_minutes ?? 0) : 0; + const rawObservedTotalMinutes = rawObservedOnlineMinutes + rawObservedOfflineMinutes; + const clampScale = rawObservedTotalMinutes > windowMinutes + ? windowMinutes / rawObservedTotalMinutes + : 1; + const estimatedOnlineMinutes = rawObservedOnlineMinutes * clampScale; + const observedOfflineMinutes = rawObservedOfflineMinutes * clampScale; + const sampledMinutes = estimatedOnlineMinutes + observedOfflineMinutes; + const baseUnknownMinutes = Math.max(0, windowMinutes - sampledMinutes); + const inferredOfflineFromRecency = status === 'offline' && minutesSinceLastSeen !== null + ? Math.max(0, Math.min(baseUnknownMinutes, minutesSinceLastSeen)) + : 0; + const estimatedOfflineMinutes = observedOfflineMinutes + inferredOfflineFromRecency; + const estimatedUnknownMinutes = Math.max(0, baseUnknownMinutes - inferredOfflineFromRecency); + const estimatedUptimePct = sampledMinutes > 0 + ? (estimatedOnlineMinutes / sampledMinutes) * 100 + : 0; + const coveragePct = windowMinutes > 0 + ? (sampledMinutes / windowMinutes) * 100 + : 0; + const activityWindow = gateway.activity_window ?? gateway.activity_7d; + const fundCountWindow = activityWindow?.fund_count ?? 0; + const settleCountWindow = activityWindow?.settle_count ?? 0; + const cancelCountWindow = activityWindow?.cancel_count ?? 0; + const totalVolumeMsatWindow = activityWindow?.total_volume_msat ?? 0; + const hasRealActivity = Boolean(activityWindow); + const realActivityScore = hasRealActivity + ? Math.max( + 0, + Math.round( + (fundCountWindow * 1.0) + + (settleCountWindow * 3.0) + + (0.5 * Math.log1p(totalVolumeMsatWindow / 1_000_000)) + - (cancelCountWindow * 1.5), + ), + ) + : null; + + return { + ...gateway, + firstSeenDate, + lastSeenDate, + status, + minutesSinceLastSeen, + inferredOfflineMinutes: inferredOfflineFromRecency, + estimatedOfflineMinutes, + estimatedOnlineMinutes, + estimatedUnknownMinutes, + estimatedUptimePct, + coveragePct, + realActivityScore, + fundCountWindow, + settleCountWindow, + cancelCountWindow, + totalVolumeMsatWindow, + }; + }) + .sort((a, b) => { + const left = a.lastSeenDate?.getTime() ?? 0; + const right = b.lastSeenDate?.getTime() ?? 0; + return right - left; + }); + }, [gateways, windowMinutes]); + + 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 vetted = rows.filter((row) => row.vetted).length; + + return { total, online, degraded, offline, vetted }; + }, [rows]); + + const avgUptime = useMemo(() => { + const observedRows = rows.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]); + + const avgCoverage = useMemo(() => { + if (rows.length === 0) return 0; + const total = rows.reduce((sum, row) => sum + row.coveragePct, 0); + return total / rows.length; + }, [rows]); + + const uptimeStrips = useMemo(() => { + return [...rows] + .sort((a, b) => { + if (b.estimatedUptimePct !== a.estimatedUptimePct) { + return b.estimatedUptimePct - a.estimatedUptimePct; + } + return (b.realActivityScore ?? 0) - (a.realActivityScore ?? 0); + }) + .map((gateway) => ({ + gateway, + strip: buildUptimeStrip(gateway, windowMinutes), + })); + }, [rows, windowMinutes]); + + const mostActive = useMemo(() => { + return rows + .filter((row) => row.realActivityScore !== null) + .sort((a, b) => (b.realActivityScore ?? 0) - (a.realActivityScore ?? 0)) + .slice(0, 5); + }, [rows]); + + const hasRealActivityData = useMemo( + () => rows.some((row) => row.realActivityScore !== null), + [rows], + ); + + if (loading) { + return ( +
+
Loading gateways...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + const federationName = federation?.name || 'Federation'; + const metricsWindow = (rows.find((row) => row.metrics_window)?.metrics_window ?? timeWindow) + .toUpperCase(); + + return ( +
+
+ + ← Back to Federation Details + +
+ +

+ {federationName} Gateways +

+
+

+ Gateway registry view with latest-seen freshness and federation LN gateway metadata. +

+
+ {windowLoading && ( + Updating... + )} + {(['1h', '24h', '7d', '30d', '90d'] as GatewayWindow[]).map((window) => ( + + ))} +
+
+ + {gatewayWarning && ( + + )} + +
+
+
Total Gateways
+
{totals.total}
+
+
+
Online
+
{totals.online}
+
+
+
Degraded
+
{totals.degraded}
+
+
+
Offline
+
{totals.offline}
+
+
+
Vetted
+
{totals.vetted}
+
+
+
Avg Uptime (Observed)
+
{avgUptime.toFixed(1)}%
+
+ Window: {timeWindow.toUpperCase()} · Coverage: {avgCoverage.toFixed(1)}% +
+
+
+ +
+
+

+ Gateway Uptime ({timeWindow.toUpperCase()} Window) +

+

+ 30-bucket availability strip over the selected window (newest at right). +

+
oldest ← newest
+
+ {uptimeStrips.length === 0 && ( +
No gateways discovered yet.
+ )} + {uptimeStrips.map(({ gateway, strip }) => ( +
+
+ + {gateway.lightning_alias || shortId(gateway.gateway_id)} + + + {gateway.estimatedUptimePct.toFixed(1)}% + +
+
+ {strip.map((status, idx) => ( +
+ ))} +
+
+ ))} + {uptimeStrips.length > 0 && ( +
+
Online
+
Degraded
+
Offline
+
Unknown
+
+ )} +
+
+ +
+

+ Most Active Gateways (Real {metricsWindow}) +

+

+ Ranked by real {metricsWindow} contract activity (fund/settle/cancel + volume). +

+ {!hasRealActivityData && ( +

+ No gateway contract events were found in the backend {metricsWindow} activity window. +

+ )} +
+ {mostActive.map((gateway) => ( +
+
+ {shortId(gateway.gateway_id)} + + {(gateway.realActivityScore ?? 0).toLocaleString()} + +
+
+ {metricsWindow}: fund {gateway.fundCountWindow} · settle {gateway.settleCountWindow} · cancel {gateway.cancelCountWindow} +
+
+
0 + ? Math.max( + 8, + ((gateway.realActivityScore ?? 0) / (mostActive[0].realActivityScore ?? 1)) * 100, + ) + : 8 + }%`, + }} + /> +
+
+ ))} +
+
+
+ +
+
+ Gateway Details +

+ Complete list of gateways discovered for this federation. +

+
+ + + + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + + {rows.map((gateway) => ( + + + + + + + + + + + + + + ))} + +
GatewayStatusUptime %OnlineOfflineUnknownActivityVettedFirst SeenLast SeenAPI Endpoint
+ No gateways available for this federation yet. +
+
+ {gateway.lightning_alias || 'Unnamed Gateway'} +
+
+ {shortId(gateway.gateway_id)} +
+
+ Node: {shortId(gateway.node_pub_key)} +
+
+ + {gateway.status} + +
+ {formatRelative(gateway.lastSeenDate)} +
+
+ {gateway.estimatedUptimePct.toFixed(1)}% + + {formatDuration(gateway.estimatedOnlineMinutes)} + + {formatDuration(gateway.estimatedOfflineMinutes)} + + {formatDuration(gateway.estimatedUnknownMinutes)} + + {gateway.realActivityScore !== null ? ( + <> +
+ {gateway.realActivityScore.toLocaleString()} +
+
+ {metricsWindow} F:{gateway.fundCountWindow} S:{gateway.settleCountWindow} C:{gateway.cancelCountWindow}
+ Vol: {formatMsats(gateway.totalVolumeMsatWindow)} +
+ + ) : ( +
+ N/A (no real {metricsWindow} data) +
+ )} +
+ + {gateway.vetted ? 'Yes' : 'No'} + + + {formatDateTime(gateway.firstSeenDate)} + + {formatDateTime(gateway.lastSeenDate)} + + + {gateway.api_endpoint} + + {gateway.raw && ( +
+ + Raw announcement + +
+                        {JSON.stringify(gateway.raw, null, 2)}
+                      
+
+ )} +
+
+
+ ); +} diff --git a/fmo_frontend_react/src/services/api.ts b/fmo_frontend_react/src/services/api.ts index 40c180a..ac17fdc 100644 --- a/fmo_frontend_react/src/services/api.ts +++ b/fmo_frontend_react/src/services/api.ts @@ -1,4 +1,9 @@ -import type { FedimintTotals, FederationSummary } from '../types/api'; +import type { + FedimintTotals, + FederationSummary, + GatewayInfo, + GatewayWindow, +} from '../types/api'; const BASE_URL = import.meta.env.VITE_FMO_API_BASE_URL || 'https://observer.fedimint.org/api'; @@ -74,4 +79,22 @@ export const api = { } return response.json(); }, + + async getFederationGateways(id: string, window?: GatewayWindow): Promise { + 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 getFederationGatewaysByInvite(inviteCode: string): Promise { + const encodedInvite = encodeURIComponent(inviteCode); + const response = await fetch(`${BASE_URL}/config/${encodedInvite}/gateways`); + if (!response.ok) { + throw new Error(`Failed to fetch gateways by invite (${response.status})`); + } + return response.json(); + }, }; diff --git a/fmo_frontend_react/src/types/api.ts b/fmo_frontend_react/src/types/api.ts index ba76ffd..17849d3 100644 --- a/fmo_frontend_react/src/types/api.ts +++ b/fmo_frontend_react/src/types/api.ts @@ -30,6 +30,38 @@ export interface FederationUtxo { amount: number; } +export interface GatewayInfo { + gateway_id: string; + node_pub_key: string; + lightning_alias: string; + api_endpoint: string; + vetted: boolean; + raw?: Record; + first_seen?: string; + last_seen?: string; + activity_7d?: GatewayActivityMetrics; + activity_window?: GatewayActivityMetrics; + uptime_window?: GatewayUptimeMetrics; + metrics_window?: GatewayWindow; +} + +export interface GatewayActivityMetrics { + fund_count: number; + settle_count: number; + cancel_count: number; + total_volume_msat: number; +} + +export interface GatewayUptimeMetrics { + sample_count: number; + seen_samples: number; + online_minutes: number; + offline_minutes: number; + uptime_pct: number; +} + +export type GatewayWindow = '1h' | '24h' | '7d' | '30d' | '90d'; + export interface GuardianHealth { avg_uptime: number; avg_latency: number; diff --git a/fmo_server/schema/v9.sql b/fmo_server/schema/v9.sql index b262f74..a6b3dfa 100644 --- a/fmo_server/schema/v9.sql +++ b/fmo_server/schema/v9.sql @@ -21,4 +21,15 @@ CREATE TABLE IF NOT EXISTS gateways ( CREATE INDEX IF NOT EXISTS gateways_federation_id ON gateways (federation_id); CREATE INDEX IF NOT EXISTS gateways_node_pub_key ON gateways (node_pub_key); +CREATE TABLE IF NOT EXISTS gateway_poll_snapshots ( + federation_id BYTEA NOT NULL REFERENCES federations (federation_id), + gateway_id TEXT NOT NULL, + poll_time TIMESTAMPTZ NOT NULL, + is_seen BOOLEAN NOT NULL, + PRIMARY KEY (federation_id, gateway_id, poll_time) +); + +CREATE INDEX IF NOT EXISTS gateway_poll_snapshots_fed_time + ON gateway_poll_snapshots (federation_id, poll_time); + COMMIT; diff --git a/fmo_server/src/config/mod.rs b/fmo_server/src/config/mod.rs index 4f577b0..27d2265 100644 --- a/fmo_server/src/config/mod.rs +++ b/fmo_server/src/config/mod.rs @@ -6,6 +6,7 @@ use axum::routing::get; use axum::{Json, Router}; use fedimint_core::config::{FederationId, JsonClientConfig}; use fedimint_core::invite_code::InviteCode; +use fmo_api_types::GatewayInfo; use reqwest::Method; use tower_http::cors::{Any, CorsLayer}; use tracing::warn; @@ -14,6 +15,7 @@ use crate::config::id::fetch_federation_id; use crate::config::meta::fetch_federation_meta; use crate::config::modules::fetch_federation_module_kinds; use crate::error::Result; +use crate::federation::gateways::fetch_gateways_for_config; use crate::util::config_to_json; use crate::AppState; @@ -28,6 +30,7 @@ pub mod modules; pub fn get_config_routes() -> Router { let router = Router::new() .route("/:invite", get(fetch_federation_config)) + .route("/:invite/gateways", get(fetch_federation_gateways)) .route("/:invite/meta", get(fetch_federation_meta)) .route("/:invite/id", get(fetch_federation_id)) .route("/:invite/module_kinds", get(fetch_federation_module_kinds)); @@ -56,6 +59,16 @@ pub async fn fetch_federation_config( .into()) } +pub async fn fetch_federation_gateways( + Path(invite): Path, +) -> Result>> { + let config = fedimint_api_client::api::net::Connector::default() + .download_from_invite_code(&invite) + .await?; + let gateways = fetch_gateways_for_config(&config).await?; + Ok(gateways.into()) +} + #[derive(Default, Debug, Clone)] pub struct FederationConfigCache { federations: Arc>>, diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs index ac157c0..5771b37 100644 --- a/fmo_server/src/federation/gateways.rs +++ b/fmo_server/src/federation/gateways.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use std::time::Duration; -use anyhow::Context; -use axum::extract::{Path, State}; +use anyhow::{bail, Context}; +use axum::extract::{Path, Query, State}; use axum::Json; +use chrono::{DateTime, Utc}; use fedimint_api_client::api::{DynGlobalApi, FederationApiExt}; use fedimint_core::config::{ClientConfig, FederationId}; use fedimint_core::core::ModuleInstanceId; @@ -11,12 +12,145 @@ use fedimint_core::encoding::Encodable; use fedimint_core::module::ApiRequestErased; use fedimint_ln_common::federation_endpoint_constants::LIST_GATEWAYS_ENDPOINT; use fedimint_ln_common::LightningGatewayAnnouncement; -use fmo_api_types::GatewayInfo; +use fmo_api_types::{GatewayActivityMetrics, GatewayInfo, GatewayUptimeMetrics}; +use futures::future::join_all; +use serde::Deserialize; use tracing::{info, warn}; use crate::federation::observer::FederationObserver; 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; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum GatewayMetricsWindow { + H1, + H24, + D7, + D30, + D90, +} + +impl GatewayMetricsWindow { + 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" + ), + } + } + + fn label(self) -> &'static str { + match self { + Self::H1 => "1h", + Self::H24 => "24h", + Self::D7 => "7d", + Self::D30 => "30d", + Self::D90 => "90d", + } + } + + 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), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct GetFederationGatewaysParams { + window: Option, +} + +pub(crate) async fn fetch_gateways_for_config( + config: &ClientConfig, +) -> anyhow::Result> { + let api = DynGlobalApi::from_endpoints( + config + .global + .api_endpoints + .iter() + .map(|(&peer_id, peer_url)| (peer_id, peer_url.url.clone())), + &None, + ) + .await?; + + let ln_instance_id = config + .modules + .iter() + .find_map(|(&instance_id, module)| (module.kind.as_str() == "ln").then_some(instance_id)) + .context("No LN module found in federation config")?; + + let peer_ids: Vec = + config.global.api_endpoints.keys().copied().collect(); + let mut merged: HashMap = HashMap::new(); + let peer_results = join_all(peer_ids.iter().copied().map(|peer_id| { + let api = api.clone(); + async move { + let result: anyhow::Result> = api + .with_module(ln_instance_id) + .request_single_peer( + LIST_GATEWAYS_ENDPOINT.to_owned(), + ApiRequestErased::default(), + peer_id, + ) + .await + .map_err(anyhow::Error::from) + .and_then(|v| serde_json::from_value(v).map_err(anyhow::Error::from)); + (peer_id, result) + } + })) + .await; + + for (peer_id, result) in peer_results { + match result { + Ok(gateways) => { + for gw in gateways { + merged.entry(gw.info.gateway_id.to_string()).or_insert(gw); + } + } + Err(e) => { + warn!( + "Failed to fetch live gateways from peer {}: {:?}", + peer_id, e + ); + } + } + } + + merged + .into_values() + .map(|gw| { + let raw = serde_json::to_value(&gw)?; + Ok(GatewayInfo { + gateway_id: gw.info.gateway_id.to_string(), + node_pub_key: gw.info.node_pub_key.to_string(), + lightning_alias: gw.info.lightning_alias, + api_endpoint: gw.info.api.to_string(), + vetted: gw.vetted, + raw: Some(raw), + first_seen: None, + last_seen: None, + activity_7d: None, + activity_window: None, + uptime_window: None, + metrics_window: None, + }) + }) + .collect() +} + impl FederationObserver { /// Background task: poll the LN module on this federation for currently /// registered gateways and persist them. Runs in a loop until cancelled. @@ -25,7 +159,7 @@ impl FederationObserver { federation_id: FederationId, config: ClientConfig, ) -> anyhow::Result<()> { - const POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); + const POLL_INTERVAL: Duration = Duration::from_secs(GATEWAY_POLL_INTERVAL_MINUTES * 60); let api = DynGlobalApi::from_endpoints( config @@ -73,8 +207,8 @@ impl FederationObserver { // Query all peers and merge by gateway_id — each guardian has their own // registry so we take the union across all peers. let mut merged: HashMap = HashMap::new(); - - for &peer_id in peer_ids { + let mut successful_peer_queries: u32 = 0; + let peer_results = join_all(peer_ids.iter().copied().map(|peer_id| async move { let result: anyhow::Result> = api .with_module(ln_instance_id) .request_single_peer( @@ -85,9 +219,14 @@ impl FederationObserver { .await .map_err(anyhow::Error::from) .and_then(|v| serde_json::from_value(v).map_err(anyhow::Error::from)); + (peer_id, result) + })) + .await; + for (peer_id, result) in peer_results { match result { Ok(gateways) => { + successful_peer_queries += 1; for gw in gateways { merged.entry(gw.info.gateway_id.to_string()).or_insert(gw); } @@ -101,22 +240,57 @@ impl FederationObserver { } } - let conn = self.connection().await?; + if successful_peer_queries == 0 { + bail!( + "No successful gateway registry responses from any federation peer for {}", + federation_id + ); + } + + let mut conn = self.connection().await?; + let dbtx = conn.transaction().await?; let now = chrono::Utc::now(); let federation_id_bytes = federation_id.consensus_encode_to_vec(); + let mut gateway_ids = Vec::with_capacity(merged.len()); + let mut node_pub_keys = Vec::with_capacity(merged.len()); + let mut api_endpoints = Vec::with_capacity(merged.len()); + let mut lightning_aliases = Vec::with_capacity(merged.len()); + let mut vetted_flags = Vec::with_capacity(merged.len()); + let mut raw_announcements = Vec::with_capacity(merged.len()); + for (gateway_id, gw) in &merged { - let node_pub_key = gw.info.node_pub_key.to_string(); - let api_endpoint = gw.info.api.to_string(); - let lightning_alias = &gw.info.lightning_alias; - let vetted = gw.vetted; - let raw = serde_json::to_value(gw)?; + gateway_ids.push(gateway_id.clone()); + node_pub_keys.push(gw.info.node_pub_key.to_string()); + api_endpoints.push(gw.info.api.to_string()); + lightning_aliases.push(gw.info.lightning_alias.clone()); + vetted_flags.push(gw.vetted); + raw_announcements.push(serde_json::to_string(gw)?); + } - conn.execute( + if !gateway_ids.is_empty() { + dbtx.execute( "INSERT INTO gateways (federation_id, gateway_id, node_pub_key, api_endpoint, lightning_alias, vetted, raw, first_seen, last_seen) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + SELECT + $1, + gw.gateway_id, + gw.node_pub_key, + gw.api_endpoint, + gw.lightning_alias, + gw.vetted, + gw.raw_json::jsonb, + $2, + $2 + FROM UNNEST( + $3::text[], + $4::text[], + $5::text[], + $6::text[], + $7::boolean[], + $8::text[] + ) AS gw(gateway_id, node_pub_key, api_endpoint, lightning_alias, vetted, raw_json) ON CONFLICT (federation_id, gateway_id) DO UPDATE SET node_pub_key = EXCLUDED.node_pub_key, api_endpoint = EXCLUDED.api_endpoint, @@ -126,29 +300,76 @@ impl FederationObserver { last_seen = EXCLUDED.last_seen", &[ &federation_id_bytes, - gateway_id, - &node_pub_key, - &api_endpoint, - lightning_alias, - &vetted, - &raw, &now, + &gateway_ids, + &node_pub_keys, + &api_endpoints, + &lightning_aliases, + &vetted_flags, + &raw_announcements, ], ) .await?; } + dbtx.execute( + "WITH current_gateway_ids AS ( + SELECT UNNEST($3::text[]) AS gateway_id + ), + all_gateway_ids AS ( + SELECT gateway_id, TRUE AS is_seen + FROM current_gateway_ids + UNION + SELECT g.gateway_id, FALSE AS is_seen + FROM gateways g + WHERE g.federation_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM current_gateway_ids c + WHERE c.gateway_id = g.gateway_id + ) + ) + INSERT INTO gateway_poll_snapshots + (federation_id, gateway_id, poll_time, is_seen) + SELECT + $1, + a.gateway_id, + $2, + a.is_seen + FROM all_gateway_ids a + ON CONFLICT DO NOTHING", + &[&federation_id_bytes, &now, &gateway_ids], + ) + .await?; + + let prune_interval_secs = GATEWAY_PRUNE_INTERVAL_HOURS * 60 * 60; + let should_prune = now.timestamp().rem_euclid(prune_interval_secs) + < (GATEWAY_POLL_INTERVAL_MINUTES as i64 * 60); + let deleted_snapshots = if should_prune { + let retention_cutoff = now - chrono::Duration::days(GATEWAY_SNAPSHOT_RETENTION_DAYS); + dbtx.execute( + "DELETE FROM gateway_poll_snapshots + WHERE federation_id = $1 + AND poll_time < $2", + &[&federation_id_bytes, &retention_cutoff], + ) + .await? + } else { + 0 + }; + dbtx.commit().await?; + info!( - "Stored {} gateway(s) for federation {}", - merged.len(), - federation_id + "Stored {} seen gateway(s), persisted poll snapshots for federation {}, deleted {} old snapshots", + merged.len(), federation_id, deleted_snapshots ); Ok(()) } - pub async fn list_federation_gateways( + async fn list_federation_gateways( &self, federation_id: FederationId, + window: GatewayMetricsWindow, ) -> anyhow::Result> { #[derive(postgres_from_row::FromRow)] struct GatewayRow { @@ -158,27 +379,231 @@ impl FederationObserver { api_endpoint: String, vetted: bool, raw: serde_json::Value, + first_seen: chrono::DateTime, + last_seen: chrono::DateTime, + } + + #[derive(postgres_from_row::FromRow)] + struct GatewayActivityRow { + gateway_key: String, + fund_count: i64, + settle_count: i64, + cancel_count: i64, + total_volume_msat: i64, } + #[derive(postgres_from_row::FromRow)] + struct GatewayUptimeRow { + gateway_id: String, + seen_samples: i64, + total_samples: i64, + } + + 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(); + let rows = query::( - &self.connection().await?, - "SELECT gateway_id, node_pub_key, lightning_alias, api_endpoint, vetted, raw + &conn, + "SELECT gateway_id, node_pub_key, lightning_alias, api_endpoint, vetted, raw, first_seen, last_seen FROM gateways WHERE federation_id = $1 ORDER BY last_seen DESC", - &[&federation_id.consensus_encode_to_vec()], + &[&federation_id_bytes], + ) + .await?; + + let activity_rows = query::( + &conn, + "WITH tx_window AS ( + SELECT t.federation_id, t.txid + FROM transactions t + JOIN session_times st + ON st.federation_id = t.federation_id + AND st.session_index = t.session_index + WHERE t.federation_id = $1 + AND st.estimated_session_timestamp >= $2 + ), + window_ln_outputs AS ( + SELECT + o.federation_id, + o.txid, + o.out_index, + o.ln_contract_id, + o.ln_contract_interaction_kind, + COALESCE(o.amount_msat, 0)::bigint AS amount_msat + FROM transaction_outputs o + JOIN tx_window tw + ON tw.federation_id = o.federation_id + AND tw.txid = o.txid + WHERE o.federation_id = $1 + AND o.kind = 'ln' + AND o.ln_contract_id IS NOT NULL + ), + window_ln_inputs AS ( + SELECT + i.federation_id, + i.txid, + i.ln_contract_id + FROM transaction_inputs i + JOIN tx_window tw + ON tw.federation_id = i.federation_id + AND tw.txid = i.txid + WHERE i.federation_id = $1 + AND i.kind = 'ln' + AND i.ln_contract_id IS NOT NULL + ), + contract_map AS ( + SELECT DISTINCT ON (wlo.federation_id, wlo.ln_contract_id) + wlo.federation_id, + wlo.ln_contract_id, + COALESCE( + d.details #>> '{V0,Contract,contract,Outgoing,gateway_key}', + d.details #>> '{V0,Contract,contract,Incoming,gateway_key}' + ) AS gateway_key + FROM window_ln_outputs wlo + JOIN transaction_output_details d + ON d.federation_id = wlo.federation_id + AND d.txid = wlo.txid + AND d.out_index = wlo.out_index + WHERE wlo.ln_contract_interaction_kind = 'fund' + ORDER BY wlo.federation_id, wlo.ln_contract_id, wlo.txid, wlo.out_index + ), + events AS ( + SELECT + cm.gateway_key, + 1::bigint AS fund_count, + 0::bigint AS settle_count, + 0::bigint AS cancel_count, + wlo.amount_msat AS volume_msat + FROM window_ln_outputs wlo + JOIN contract_map cm + ON cm.federation_id = wlo.federation_id + AND cm.ln_contract_id = wlo.ln_contract_id + WHERE wlo.ln_contract_interaction_kind = 'fund' + UNION ALL + SELECT + cm.gateway_key, + 0::bigint, + 1::bigint, + 0::bigint, + 0::bigint + FROM window_ln_inputs wli + JOIN contract_map cm + ON cm.federation_id = wli.federation_id + AND cm.ln_contract_id = wli.ln_contract_id + UNION ALL + SELECT + cm.gateway_key, + 0::bigint, + 0::bigint, + 1::bigint, + 0::bigint + FROM window_ln_outputs wlo + JOIN contract_map cm + ON cm.federation_id = wlo.federation_id + AND cm.ln_contract_id = wlo.ln_contract_id + WHERE wlo.ln_contract_interaction_kind = 'cancel' + ) + SELECT + gateway_key, + SUM(fund_count)::bigint AS fund_count, + SUM(settle_count)::bigint AS settle_count, + SUM(cancel_count)::bigint AS cancel_count, + SUM(volume_msat)::bigint AS total_volume_msat + FROM events + WHERE gateway_key IS NOT NULL + GROUP BY gateway_key", + &[&federation_id_bytes, &window_start_naive], ) .await?; + let activity_by_gateway_key: HashMap = activity_rows + .into_iter() + .map(|row| { + ( + row.gateway_key, + GatewayActivityMetrics { + fund_count: row.fund_count.max(0) as u64, + settle_count: row.settle_count.max(0) as u64, + cancel_count: row.cancel_count.max(0) as u64, + total_volume_msat: row.total_volume_msat.max(0) as u64, + }, + ) + }) + .collect(); + + let uptime_rows = query::( + &conn, + "SELECT + gateway_id, + COUNT(*) FILTER (WHERE 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], + ) + .await?; + + let uptime_by_gateway_id: HashMap = uptime_rows + .into_iter() + .map(|row| { + let seen_samples = row.seen_samples.max(0) as u64; + let total_samples = row.total_samples.max(0) as u64; + let online_minutes = seen_samples.saturating_mul(GATEWAY_POLL_INTERVAL_MINUTES); + let offline_minutes = total_samples + .saturating_sub(seen_samples) + .saturating_mul(GATEWAY_POLL_INTERVAL_MINUTES); + let uptime_pct = if total_samples > 0 { + (seen_samples as f64 / total_samples as f64) * 100.0 + } else { + 0.0 + }; + ( + row.gateway_id, + GatewayUptimeMetrics { + sample_count: total_samples, + seen_samples, + online_minutes, + offline_minutes, + uptime_pct, + }, + ) + }) + .collect(); + Ok(rows .into_iter() - .map(|r| GatewayInfo { - gateway_id: r.gateway_id, - node_pub_key: r.node_pub_key, - lightning_alias: r.lightning_alias, - api_endpoint: r.api_endpoint, - vetted: r.vetted, - raw: Some(r.raw), + .map(|r| { + let activity_window = r + .raw + .pointer("/info/gateway_redeem_key") + .and_then(|v| v.as_str()) + .and_then(|gateway_key| activity_by_gateway_key.get(gateway_key).cloned()); + let uptime_window = uptime_by_gateway_id.get(&r.gateway_id).cloned(); + + GatewayInfo { + activity_7d: if window == GatewayMetricsWindow::D7 { + activity_window.clone() + } else { + None + }, + activity_window, + uptime_window, + metrics_window: Some(metrics_window.clone()), + gateway_id: r.gateway_id, + node_pub_key: r.node_pub_key, + lightning_alias: r.lightning_alias, + api_endpoint: r.api_endpoint, + vetted: r.vetted, + raw: Some(r.raw), + first_seen: Some(r.first_seen), + last_seen: Some(r.last_seen), + } }) .collect()) } @@ -186,11 +611,13 @@ 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) + .list_federation_gateways(federation_id, window) .await? .into()) } diff --git a/fmo_server/src/federation/mod.rs b/fmo_server/src/federation/mod.rs index 0dc12f9..d0521e9 100644 --- a/fmo_server/src/federation/mod.rs +++ b/fmo_server/src/federation/mod.rs @@ -1,5 +1,5 @@ pub mod db; -mod gateways; +pub(crate) mod gateways; mod guardians; mod meta; pub(crate) mod nostr; From 3e7defe9660fd8f6a513c3bfb8a9c4287a2b8b45 Mon Sep 17 00:00:00 2001 From: bansalayush247 Date: Mon, 20 Apr 2026 14:16:21 +0530 Subject: [PATCH 4/4] feat: implement GatewayWarningPage component for enhanced gateway status notifications --- .../src/components/GatewayWarningPage.tsx | 42 +++++ .../src/pages/FederationGateways.tsx | 173 ++++++++++++------ 2 files changed, 164 insertions(+), 51 deletions(-) create mode 100644 fmo_frontend_react/src/components/GatewayWarningPage.tsx diff --git a/fmo_frontend_react/src/components/GatewayWarningPage.tsx b/fmo_frontend_react/src/components/GatewayWarningPage.tsx new file mode 100644 index 0000000..54115ea --- /dev/null +++ b/fmo_frontend_react/src/components/GatewayWarningPage.tsx @@ -0,0 +1,42 @@ +export type GatewayWarningLevel = 'info' | 'warning' | 'error'; + +export interface GatewayWarningState { + level: GatewayWarningLevel; + title: string; + message: string; + detail?: string; +} + +interface GatewayWarningPageProps { + warning: GatewayWarningState; + className?: string; +} + +function levelClasses(level: GatewayWarningLevel): string { + switch (level) { + case 'info': + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200'; + case 'error': + return 'border-red-200 bg-red-50 text-red-900 dark:border-red-700 dark:bg-red-900/30 dark:text-red-200'; + default: + return 'border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-200'; + } +} + +export function GatewayWarningPage({ warning, className = '' }: GatewayWarningPageProps) { + return ( +
+

{warning.title}

+

{warning.message}

+ {warning.detail && ( +

+ {warning.detail} +

+ )} +
+ ); +} diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx index f872bc3..ca49f85 100644 --- a/fmo_frontend_react/src/pages/FederationGateways.tsx +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { api } from '../services/api'; import type { FederationSummary, GatewayInfo, GatewayWindow } from '../types/api'; -import { Alert } from '../components/Alert'; +import { GatewayWarningPage, type GatewayWarningState } from '../components/GatewayWarningPage'; type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown'; type UptimeStripStatus = 'online' | 'degraded' | 'offline' | 'unknown'; @@ -220,6 +220,106 @@ function mergeGatewayData(observedGateways: GatewayInfo[], liveGateways: Gateway return merged; } +interface GatewaySelection { + gateways: GatewayInfo[]; + warning: GatewayWarningState | null; +} + +function selectGatewayData( + observedGateways: GatewayInfo[], + liveGateways: GatewayInfo[], + observedError: string | null, + liveError: string | null, + hasInvite: boolean, +): GatewaySelection { + if (liveGateways.length > 0) { + if (observedGateways.length === 0) { + return { + gateways: liveGateways, + 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.', + }, + }; + } + + 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) { + return { + gateways: observedGateways, + warning: { + level: 'warning', + title: 'Live Lookup Failed', + message: 'Showing observed gateway data from the backend.', + detail: `Live invite lookup error: ${liveError}`, + }, + }; + } + + if (hasInvite) { + return { + gateways: observedGateways, + 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.', + }, + }; + } + + 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}`, + }, + }; + } + + if (hasInvite) { + return { + gateways: [], + 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.', + }, + }; + } + + return { + gateways: [], + warning: { + level: 'warning', + title: 'Gateway Data Unavailable', + message: 'The configured backend did not return any gateway data.', + detail: reason, + }, + }; +} + export function FederationGateways() { const { id } = useParams<{ id: string }>(); const [federation, setFederation] = useState(null); @@ -227,7 +327,7 @@ export function FederationGateways() { const [loading, setLoading] = useState(true); const [windowLoading, setWindowLoading] = useState(false); const [error, setError] = useState(null); - const [gatewayWarning, setGatewayWarning] = useState(null); + const [gatewayWarning, setGatewayWarning] = useState(null); const [timeWindow, setTimeWindow] = useState('7d'); const hasLoadedOnce = useRef(false); const requestSeq = useRef(0); @@ -295,52 +395,26 @@ export function FederationGateways() { } if (cancelled || currentRequest !== requestSeq.current) return; - if (liveGateways.length > 0) { - if (observedGateways.length === 0) { - setGateways(liveGateways); - setGatewayWarning( - 'Showing live gateway data via invite-based lookup (observed gateway data unavailable on this API backend).', - ); - } else { - setGateways(mergeGatewayData(observedGateways, liveGateways)); - setGatewayWarning( - 'Merged observed gateway history (status/activity) with live invite-based registry (latest metadata).', - ); - } - return; - } - - if (observedGateways.length > 0) { - setGateways(observedGateways); - if (liveError) { - setGatewayWarning( - `Live invite lookup failed (${liveError}); showing observed gateway data from backend.`, - ); - } else if (fed?.invite) { - setGatewayWarning( - 'Live invite lookup returned no gateways; showing observed gateway data from backend.', - ); - } - return; - } - - const reason = observedError ?? 'No gateway data available on the configured API backend.'; - setGateways([]); - if (liveError) { - setGatewayWarning( - `Gateway data is unavailable right now. ${reason}. Live fallback also failed: ${liveError}`, - ); - } else if (fed?.invite) { - setGatewayWarning('No gateway data returned from either observed or invite-based live lookup.'); - } else { - setGatewayWarning(`Gateway data is unavailable right now. ${reason}`); - } + 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(`Failed to refresh selected window: ${message}`); + setGatewayWarning({ + level: 'warning', + title: 'Refresh Failed', + message: 'Failed to refresh the selected time window.', + detail: message, + }); } } finally { if (!cancelled && currentRequest === requestSeq.current) { @@ -541,7 +615,7 @@ export function FederationGateways() { {windowLoading && ( Updating... )} - {(['1h', '24h', '7d', '30d', '90d'] as GatewayWindow[]).map((window) => ( + {(['24h', '7d', '30d', '90d'] as GatewayWindow[]).map((window) => (