diff --git a/Cargo.lock b/Cargo.lock index d3e3a08..4f8aece 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2237,6 +2237,7 @@ dependencies = [ "chrono", "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 ce83cc5..beca5ef 100644 --- a/fmo_api_types/Cargo.toml +++ b/fmo_api_types/Cargo.toml @@ -8,3 +8,4 @@ bitcoin = { version = "0.32.5", features = ["serde"] } chrono = { version = "0.4", 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 b4c6d17..8755ad0 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}; @@ -44,6 +45,7 @@ pub struct FederationUtxo { pub struct GuardianHealth { pub avg_uptime: f32, pub avg_latency: f32, + pub software_version: Option, pub latest: Option, } @@ -63,6 +65,60 @@ pub enum FederationHealth { 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, + /// 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)] pub struct NoncesRequest { pub nonces: Vec, diff --git a/fmo_frontend/src/components/federation/guardians.rs b/fmo_frontend/src/components/federation/guardians.rs index ecd6e66..a7be8b5 100644 --- a/fmo_frontend/src/components/federation/guardians.rs +++ b/fmo_frontend/src/components/federation/guardians.rs @@ -46,6 +46,9 @@ pub fn Guardians(federation_id: FederationId, guardians: Vec) -> impl let health = health.get(&PeerId::from(guardian_idx as u16)).expect("Guardian exists").clone(); let mut badges = vec![]; + let software_version = health + .software_version + .unwrap_or_else(|| "Version unknown".to_owned()); if let Some(latest) = health.latest { badges.push(view! { @@ -77,6 +80,11 @@ pub fn Guardians(federation_id: FederationId, guardians: Vec) -> impl }.into_view()); } + badges.push(view! { + + {software_version} + + }.into_view()); view! { {badges} }.into_any() } 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_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/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/FederationDetail.tsx b/fmo_frontend_react/src/pages/FederationDetail.tsx index 6ce2763..db81503 100644 --- a/fmo_frontend_react/src/pages/FederationDetail.tsx +++ b/fmo_frontend_react/src/pages/FederationDetail.tsx @@ -24,6 +24,7 @@ interface Guardian { interface GuardianHealth { avg_uptime: number; avg_latency: number; + software_version: string | null; latest: { block_height: number; block_outdated: boolean; @@ -359,6 +360,7 @@ export function FederationDetail() { const block = health?.latest ? health.latest.block_height - 1 : 0; const sessionOutdated = health?.latest?.session_outdated || false; const blockOutdated = health?.latest?.block_outdated || false; + const softwareVersion = health?.software_version || 'Version unknown'; return (
@@ -378,6 +380,9 @@ export function FederationDetail() { {isOnline ? 'Online' : 'Offline'} + + {softwareVersion} + {isOnline && ( <>
+ {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..ca49f85 --- /dev/null +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -0,0 +1,890 @@ +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 { GatewayWarningPage, type GatewayWarningState } from '../components/GatewayWarningPage'; + +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; +} + +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); + 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; + + 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); + } + } + })(); + + 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... + )} + {(['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) => ( +
+
+ + {gateway.lightning_alias || 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..29a6100 100644 --- a/fmo_frontend_react/src/types/api.ts +++ b/fmo_frontend_react/src/types/api.ts @@ -30,9 +30,42 @@ 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; + software_version: string | null; latest: GuardianHealthLatest | null; } diff --git a/fmo_server/schema/v10.sql b/fmo_server/schema/v10.sql new file mode 100644 index 0000000..045cc9b --- /dev/null +++ b/fmo_server/schema/v10.sql @@ -0,0 +1,11 @@ +BEGIN; + +INSERT INTO + schema_version (version) +VALUES + (10); + +ALTER TABLE guardian_health + ADD COLUMN IF NOT EXISTS software_version TEXT; + +COMMIT; diff --git a/fmo_server/schema/v9.sql b/fmo_server/schema/v9.sql new file mode 100644 index 0000000..a6b3dfa --- /dev/null +++ b/fmo_server/schema/v9.sql @@ -0,0 +1,35 @@ +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); + +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 new file mode 100644 index 0000000..5771b37 --- /dev/null +++ b/fmo_server/src/federation/gateways.rs @@ -0,0 +1,623 @@ +use std::collections::HashMap; +use std::time::Duration; + +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; +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::{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. + pub async fn monitor_gateways( + &self, + federation_id: FederationId, + config: ClientConfig, + ) -> anyhow::Result<()> { + const POLL_INTERVAL: Duration = Duration::from_secs(GATEWAY_POLL_INTERVAL_MINUTES * 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(); + 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( + 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) => { + successful_peer_queries += 1; + 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 + ); + } + } + } + + 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 { + 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)?); + } + + 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) + 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, + lightning_alias = EXCLUDED.lightning_alias, + vetted = EXCLUDED.vetted, + raw = EXCLUDED.raw, + last_seen = EXCLUDED.last_seen", + &[ + &federation_id_bytes, + &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 {} seen gateway(s), persisted poll snapshots for federation {}, deleted {} old snapshots", + merged.len(), federation_id, deleted_snapshots + ); + Ok(()) + } + + async fn list_federation_gateways( + &self, + federation_id: FederationId, + window: GatewayMetricsWindow, + ) -> 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, + 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::( + &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_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| { + 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()) + } +} + +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()) +} diff --git a/fmo_server/src/federation/guardians.rs b/fmo_server/src/federation/guardians.rs index dd2f159..cacac5e 100644 --- a/fmo_server/src/federation/guardians.rs +++ b/fmo_server/src/federation/guardians.rs @@ -86,7 +86,14 @@ impl FederationObserver { }); let api_latency = start_time.elapsed(); - (peer_id, status, block_height, api_latency) + let software_version = api + .fedimintd_version(peer_id) + .await + .ok() + .map(|version| version.trim().to_owned()) + .filter(|version| !version.is_empty()); + + (peer_id, status, software_version, block_height, api_latency) } })) .await; @@ -94,9 +101,19 @@ impl FederationObserver { let mut conn = self.connection().await?; let dbtx = conn.transaction().await?; let timestamp = chrono::Utc::now().naive_utc(); - for (peer_id, status, block_height, api_latency) in peer_status_responses { + for (peer_id, status, software_version, block_height, api_latency) in + peer_status_responses + { dbtx.execute( - "INSERT INTO guardian_health VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO guardian_health ( + federation_id, + time, + guardian_id, + status, + block_height, + latency_ms, + software_version + ) VALUES ($1, $2, $3, $4, $5, $6, $7)", &[ &federation_id.consensus_encode_to_vec(), ×tamp, @@ -104,6 +121,7 @@ impl FederationObserver { &status.map(|s| serde_json::to_value(s).expect("Can be serialized")), &block_height.map(|bh| bh as i32), &(api_latency.as_millis() as i32), + &software_version, ], ) .await?; @@ -128,6 +146,7 @@ impl FederationObserver { latest.guardian_id, latest.block_height, (latest.status -> 'federation' ->> 'session_count')::integer AS session_count, + latest.software_version, last30d.uptime, last30d.latency_ms FROM guardian_health latest @@ -179,6 +198,7 @@ impl FederationObserver { let health = GuardianHealth { avg_uptime: row.uptime, avg_latency: row.latency_ms, + software_version: row.software_version, latest, }; @@ -230,7 +250,11 @@ impl FederationObserver { // Special case single guardian federations to not show them as degraded if federation.guardians == 1 { - return Ok((federation_id, FederationHealth::Online)); + if federation.online_guardians >= 1 { + return Ok((federation_id, FederationHealth::Online)); + } else { + return Ok((federation_id, FederationHealth::Offline)); + } } let threshold = NumPeers::from(federation.guardians as usize).threshold(); @@ -254,6 +278,7 @@ struct GuardianHealthRow { guardian_id: i32, block_height: Option, session_count: Option, + software_version: Option, uptime: f32, latency_ms: f32, } diff --git a/fmo_server/src/federation/mod.rs b/fmo_server/src/federation/mod.rs index e54e72b..d0521e9 100644 --- a/fmo_server/src/federation/mod.rs +++ b/fmo_server/src/federation/mod.rs @@ -1,4 +1,5 @@ pub mod db; +pub(crate) mod gateways; mod guardians; mod meta; pub(crate) mod nostr; @@ -19,6 +20,7 @@ use fmo_api_types::{FederationSummary, FedimintTotals, NonceSpendInfo, NoncesReq 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 ae982f6..7bd1868 100644 --- a/fmo_server/src/federation/observer.rs +++ b/fmo_server/src/federation/observer.rs @@ -114,7 +114,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 { @@ -129,6 +129,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<()> { @@ -191,6 +209,8 @@ impl FederationObserver { "/schema/v8.sql", FederationObserver::backfill_reprocess_all_sessions ), + migration!("/schema/v9.sql"), + migration!("/schema/v10.sql"), ]; for (index, migration) in migrations.iter().enumerate() { 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":