Skip to content
Closed
15 changes: 15 additions & 0 deletions fmo_api_types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,18 @@ pub struct GatewayUptimeTrendPoint {
pub total_samples: u64,
pub uptime_pct: f64,
}

/// A federation's gateways and their availability trend for one window,
/// computed at the same moment so both always agree
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayOverview {
/// The window label, e.g. `7d`
pub window: String,
pub computed_at: DateTime<Utc>,
/// Left out when `include` doesn't ask for `gateways`
#[serde(skip_serializing_if = "Option::is_none")]
pub gateways: Option<Vec<GatewayInfo>>,
/// Left out when `include` doesn't ask for `trend`
#[serde(skip_serializing_if = "Option::is_none")]
pub uptime_trend: Option<Vec<GatewayUptimeTrendPoint>>,
}
11 changes: 10 additions & 1 deletion fmo_frontend_react/index.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en">
<head>
<script>
// Apply the saved or system theme before the first paint (same rules as useTheme.tsx)
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (theme !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/fedimint.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand Down
34 changes: 26 additions & 8 deletions fmo_frontend_react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import type { ComponentType } from 'react';
import { BrowserRouter as Router, Routes, Route, useLocation, useParams } from 'react-router-dom';
import { NavBar } from './components/NavBar';
import { ErrorBoundary, ReloadMessage } from './components/ErrorBoundary';
import { Home } from './pages/Home';
import { Nostr } from './pages/Nostr';
import { FederationDetail } from './pages/FederationDetail';
import { FederationGateways } from './pages/FederationGateways';
import { useTheme } from './hooks/useTheme';

// The router reuses a page when only :id changes; remount it so it never shows
// the previous federation's data under the new URL
function PerFederation({ page: Page }: { page: ComponentType }) {
const { id } = useParams<{ id: string }>();
return <Page key={id} />;
}

function AppRoutes() {
const { pathname } = useLocation();
return (
<ErrorBoundary key={pathname} fallback={<ReloadMessage message="Something went wrong while showing this page." />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/nostr" element={<Nostr />} />
<Route path="/federations/:id" element={<PerFederation page={FederationDetail} />} />
<Route path="/federations/:id/gateways" element={<PerFederation page={FederationGateways} />} />
<Route path="*" element={<div className="p-4 text-gray-900 dark:text-white">Page not found</div>} />
</Routes>
</ErrorBoundary>
);
}

function App() {
const { theme, toggleTheme } = useTheme();

return (
<Router>
<main className="container mx-auto max-w-6xl px-4 min-h-screen pb-4">
<NavBar theme={theme} onToggleTheme={toggleTheme} />
<Routes>
<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>
<AppRoutes />
</main>
</Router>
);
Expand Down
42 changes: 42 additions & 0 deletions fmo_frontend_react/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';

interface ErrorBoundaryProps {
fallback: ReactNode;
children: ReactNode;
}

interface ErrorBoundaryState {
failed: boolean;
}

// Without a boundary, React unmounts the whole app on a render error, leaving a blank page
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { failed: false };

static getDerivedStateFromError(): ErrorBoundaryState {
return { failed: true };
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Render error:', error, info.componentStack);
}

render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}

export function ReloadMessage({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 p-6 text-center text-sm text-gray-600 dark:text-gray-300">
<p>{message}</p>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-lg border border-blue-300 bg-blue-50 px-4 py-2 font-semibold text-blue-800 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-200"
>
Reload page
</button>
</div>
);
}
13 changes: 12 additions & 1 deletion fmo_frontend_react/src/components/GatewayWarningPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface GatewayWarningState {
interface GatewayWarningPageProps {
warning: GatewayWarningState;
className?: string;
action?: { label: string; onClick: () => void; busy?: boolean };
}

function levelClasses(level: GatewayWarningLevel): string {
Expand All @@ -23,7 +24,7 @@ function levelClasses(level: GatewayWarningLevel): string {
}
}

export function GatewayWarningPage({ warning, className = '' }: GatewayWarningPageProps) {
export function GatewayWarningPage({ warning, className = '', action }: GatewayWarningPageProps) {
return (
<section
className={`rounded-lg border p-4 sm:p-5 ${levelClasses(warning.level)} ${className}`}
Expand All @@ -37,6 +38,16 @@ export function GatewayWarningPage({ warning, className = '' }: GatewayWarningPa
{warning.detail}
</p>
)}
{action && (
<button
type="button"
onClick={action.onClick}
aria-disabled={action.busy}
className={`mt-3 rounded-md border border-current px-3 py-1.5 text-xs font-semibold ${action.busy ? 'cursor-wait opacity-70' : 'hover:opacity-80'}`}
>
{action.label}
</button>
)}
</section>
);
}
13 changes: 13 additions & 0 deletions fmo_frontend_react/src/components/UptimeTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsOption } from 'echarts';

// Register only what the chart uses instead of loading all of echarts
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

export function UptimeTrendChart({ option }: { option: EChartsOption }) {
return <ReactEChartsCore echarts={echarts} option={option} style={{ height: 210 }} notMerge lazyUpdate />;
}
9 changes: 3 additions & 6 deletions fmo_frontend_react/src/hooks/useTheme.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { readStorage, writeStorage } from '../utils/storage';

export type Theme = 'light' | 'dark' | 'auto';

Expand All @@ -15,7 +16,7 @@ const getStoredTheme = (): Theme | null => {
if (typeof window === 'undefined') {
return null;
}
const stored = window.localStorage.getItem('theme');
const stored = readStorage('theme');
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
return stored;
}
Expand Down Expand Up @@ -100,11 +101,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {

// Save to localStorage, or clear it for auto mode
if (typeof window !== 'undefined') {
if (newTheme === 'auto') {
window.localStorage.removeItem('theme');
} else {
window.localStorage.setItem('theme', newTheme);
}
writeStorage('theme', newTheme === 'auto' ? null : newTheme);
}
return newTheme;
});
Expand Down
15 changes: 15 additions & 0 deletions fmo_frontend_react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './hooks/useTheme'

// A tab opened before a deploy still points at the previous build's chunks, which the
// deploy removed. Reload once to pick up the new build instead of failing the page.
// The guard is per build, so a chunk that keeps failing can never cause a reload loop.
const CHUNK_RELOAD_KEY = 'chunkReloadBuild'
window.addEventListener('vite:preloadError', (event) => {
try {
if (sessionStorage.getItem(CHUNK_RELOAD_KEY) === import.meta.url) return // let the error boundary show
sessionStorage.setItem(CHUNK_RELOAD_KEY, import.meta.url)
event.preventDefault()
window.location.reload()
} catch {
// Without storage there is no loop guard, so leave it to the error boundary
}
})

createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
Expand Down
Loading
Loading