Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions fmo_api_types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
56 changes: 56 additions & 0 deletions fmo_api_types/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -44,6 +45,7 @@ pub struct FederationUtxo {
pub struct GuardianHealth {
pub avg_uptime: f32,
pub avg_latency: f32,
pub software_version: Option<String>,
pub latest: Option<GuardianHealthLatest>,
}

Expand All @@ -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<serde_json::Value>,
/// First time this gateway was seen by the observer
#[serde(skip_serializing_if = "Option::is_none")]
pub first_seen: Option<DateTime<Utc>>,
/// Most recent time this gateway was seen by the observer
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen: Option<DateTime<Utc>>,
/// Real LN activity metrics over the last 7 days
#[serde(skip_serializing_if = "Option::is_none")]
pub activity_7d: Option<GatewayActivityMetrics>,
/// Real LN activity metrics over the requested API window
#[serde(skip_serializing_if = "Option::is_none")]
pub activity_window: Option<GatewayActivityMetrics>,
/// Uptime metrics computed from periodic gateway snapshots over the
/// requested window
#[serde(skip_serializing_if = "Option::is_none")]
pub uptime_window: Option<GatewayUptimeMetrics>,
/// The window label used for `activity_window` and `uptime_window`, e.g.
/// `7d`
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_window: Option<String>,
}

#[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<String>,
Expand Down
8 changes: 8 additions & 0 deletions fmo_frontend/src/components/federation/guardians.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ pub fn Guardians(federation_id: FederationId, guardians: Vec<Guardian>) -> 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! {
<Badge level=BadgeLevel::Success>
Expand Down Expand Up @@ -77,6 +80,11 @@ pub fn Guardians(federation_id: FederationId, guardians: Vec<Guardian>) -> impl
</Badge>
}.into_view());
}
badges.push(view! {
<Badge level=BadgeLevel::Info>
{software_version}
</Badge>
}.into_view());

view! { {badges} }.into_any()
}
Expand Down
50 changes: 50 additions & 0 deletions fmo_frontend/src/components/federation/stars_selector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use leptos::prelude::*;

#[component]
pub fn StarsSelector(default_value: u8, selected_stars: WriteSignal<u8>) -> 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! {
<svg
class={
if fill {
"w-6 h-6 ms-2 text-yellow-300"
} else {
"w-6 h-6 ms-2 text-gray-300 dark:text-gray-500"
}
}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
stroke=border.then_some("orange")
stroke-width=border.then_some("2px")
viewBox="0 0 22 20"
on:mouseenter=move |_| {set_hover.set(Some(star_idx));}
on:click=move |_| {
set_selected.set(star_idx);
selected_stars.set(star_idx);
}
on:mouseleave=move |_| {set_hover.set(None);}
>
<path d="M20.924 7.625a1.523 1.523 0 0 0-1.238-1.044l-5.051-.734-2.259-4.577a1.534 1.534 0 0 0-2.752 0L7.365 5.847l-5.051.734A1.535 1.535 0 0 0 1.463 9.2l3.656 3.563-.863 5.031a1.532 1.532 0 0 0 2.226 1.616L11 17.033l4.518 2.375a1.534 1.534 0 0 0 2.226-1.617l-.863-5.03L20.537 9.2a1.523 1.523 0 0 0 .387-1.575Z"/>
</svg>
}
};

view! {
<div
class="inline-flex px-1 py-2"
on:mouseleave=move |_| {set_hover.set(None);}
>
{ 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::<Vec<_>>()
}}
</div>
}
}
2 changes: 2 additions & 0 deletions fmo_frontend_react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -16,6 +17,7 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/nostr" element={<Nostr />} />
<Route path="/federations/:id" element={<FederationDetail />} />
<Route path="/federations/:id/gateways" element={<FederationGateways />} />
<Route path="*" element={<div className="p-4 text-gray-900 dark:text-white">Page not found</div>} />
</Routes>
</main>
Expand Down
42 changes: 42 additions & 0 deletions fmo_frontend_react/src/components/GatewayWarningPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
className={`rounded-lg border p-4 sm:p-5 ${levelClasses(warning.level)} ${className}`}
role="status"
aria-live="polite"
>
<h2 className="text-sm sm:text-base font-semibold">{warning.title}</h2>
<p className="mt-1 text-sm leading-relaxed">{warning.message}</p>
{warning.detail && (
<p className="mt-2 text-xs sm:text-sm opacity-90 break-words">
{warning.detail}
</p>
)}
</section>
);
}
15 changes: 15 additions & 0 deletions fmo_frontend_react/src/pages/FederationDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div key={guardian.id} className="border-b border-gray-200 dark:border-gray-700 pb-3 sm:pb-4 last:border-0">
Expand All @@ -378,6 +380,9 @@ export function FederationDetail() {
<Badge level={isOnline ? 'success' : 'error'}>
{isOnline ? 'Online' : 'Offline'}
</Badge>
<Badge level="info">
{softwareVersion}
</Badge>
{isOnline && (
<>
<Badge
Expand Down Expand Up @@ -438,6 +443,16 @@ export function FederationDetail() {
{config?.confirmations_required || 'N/A'}
</div>
</div>
{id && (
<div>
<Link
to={`/federations/${id}/gateways`}
className="inline-flex items-center justify-center w-full px-4 py-2.5 text-sm font-medium text-white bg-blue-700 hover:bg-blue-800 rounded-lg focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Gateway Details
</Link>
</div>
)}
{federation?.invite && hasOnlineGuardian && (
<div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mb-2">Invite Link</div>
Expand Down
Loading
Loading