diff --git a/dashboard/src/components/NotificationHealthPanel.tsx b/dashboard/src/components/NotificationHealthPanel.tsx new file mode 100644 index 0000000..b726ebf --- /dev/null +++ b/dashboard/src/components/NotificationHealthPanel.tsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + fetchScheduleStats, + fetchHealth, + fetchAnalytics, +} from '../services/notificationHealthApi'; +import type { + ScheduleStatsResponse, + HealthResponse, + NotificationAnalyticsSnapshot, +} from '../types/notificationHealth'; +import { formatTimestampShort } from '../utils/formatTime'; +import { formatDuration } from '../utils/formatDuration'; + +const DEFAULT_POLL_INTERVAL_MS = 5000; + +function serviceStatusLabel(status: string): string { + switch (status) { + case 'ok': + return 'Healthy'; + case 'error': + return 'Error'; + case 'not_configured': + return 'Not Configured'; + default: + return 'Unknown'; + } +} + +function serviceStatusClass(status: string): string { + switch (status) { + case 'ok': + return 'notification-health__service--ok'; + case 'error': + return 'notification-health__service--error'; + case 'not_configured': + return 'notification-health__service--not-configured'; + default: + return 'notification-health__service--unknown'; + } +} + +function overallStatusLabel(status: string): string { + switch (status) { + case 'ok': + return 'Healthy'; + case 'degraded': + return 'Degraded'; + case 'error': + return 'Error'; + default: + return 'Unknown'; + } +} + +function overallStatusClass(status: string): string { + switch (status) { + case 'ok': + return 'notification-health__status--ok'; + case 'degraded': + return 'notification-health__status--degraded'; + case 'error': + return 'notification-health__status--error'; + default: + return 'notification-health__status--unknown'; + } +} + +export function NotificationHealthPanel(props: { healthUrl: string; pollIntervalMs?: number }) { + const pollIntervalMs = props.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const [scheduleStats, setScheduleStats] = useState(null); + const [health, setHealth] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [error, setError] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [lastUpdated, setLastUpdated] = useState(Date.now()); + const abortRef = useRef(null); + + const effectivePollIntervalMs = useMemo(() => { + if (typeof document === 'undefined') return pollIntervalMs; + return document.visibilityState === 'hidden' ? pollIntervalMs * 3 : pollIntervalMs; + }, [pollIntervalMs]); + + const refresh = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsRefreshing(true); + setError(null); + + try { + const [scheduleStatsData, healthData, analyticsData] = await Promise.allSettled([ + fetchScheduleStats(props.healthUrl), + fetchHealth(props.healthUrl), + fetchAnalytics(props.healthUrl), + ]); + + if (scheduleStatsData.status === 'fulfilled') { + setScheduleStats(scheduleStatsData.value); + } + if (healthData.status === 'fulfilled') { + setHealth(healthData.value); + } + if (analyticsData.status === 'fulfilled') { + setAnalytics(analyticsData.value); + } + + const allRejected = + scheduleStatsData.status === 'rejected' && + healthData.status === 'rejected' && + analyticsData.status === 'rejected'; + + if (allRejected) { + const errors = [scheduleStatsData, healthData, analyticsData].map( + (p) => (p as PromiseRejectedResult).reason + ); + setError(errors.map((e) => (e instanceof Error ? e.message : String(e))).join(', ')); + } + + setLastUpdated(Date.now()); + } catch (err) { + if ((err as any)?.name === 'AbortError') return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsRefreshing(false); + } + }, [props.healthUrl]); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + + const schedule = (ms: number) => { + if (cancelled) return; + timer = setTimeout(async () => { + await refresh(); + schedule(effectivePollIntervalMs); + }, ms); + }; + + void refresh(); + schedule(effectivePollIntervalMs); + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void refresh(); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + cancelled = true; + abortRef.current?.abort(); + if (timer) clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [effectivePollIntervalMs, refresh]); + + const overallStatus = health?.status ?? 'unknown'; + const successRate = analytics?.overall.successRate ?? 0; + + return ( +
+
+
+

Monitor

+

Notification Health

+
+ +
+ + {overallStatusLabel(overallStatus)} + + + {isRefreshing ? 'Updating…' : `Updated ${formatTimestampShort(lastUpdated)}`} + +
+
+ + {error && ( +

+ {error} +

+ )} + +
+
+

Queue Health

+
+ {scheduleStats && ( + <> +
+
Pending
+
{scheduleStats.pending.toLocaleString()}
+
+
+
Processing
+
{scheduleStats.processing.toLocaleString()}
+
+
+
Completed
+
{scheduleStats.completed.toLocaleString()}
+
+
+
Failed
+
{scheduleStats.failed.toLocaleString()}
+
+
+
Overdue
+
{scheduleStats.overdue.toLocaleString()}
+
+ + )} +
+
+ +
+

Delivery Status

+
+ {analytics && ( + <> +
+
Success Rate
+
{(successRate * 100).toFixed(1)}%
+
+
+
Total Delivered
+
{analytics.overall.total.toLocaleString()}
+
+
+
Success
+
{analytics.overall.success.toLocaleString()}
+
+
+
Failure
+
{analytics.overall.failure.toLocaleString()}
+
+
+
Avg Duration
+
{formatDuration(analytics.overall.averageDurationMs)}
+
+ + )} +
+
+ +
+

Service Indicators

+
+ {health && ( + <> +
+
Stellar RPC
+
+ {serviceStatusLabel(health.services.stellarRpc.status)} +
+ {health.services.stellarRpc.latencyMs && ( +
+ {health.services.stellarRpc.latencyMs}ms +
+ )} + {health.services.stellarRpc.detail && ( +
+ {health.services.stellarRpc.detail} +
+ )} +
+
+
Discord
+
+ {serviceStatusLabel(health.services.discord.status)} +
+ {health.services.discord.latencyMs && ( +
+ {health.services.discord.latencyMs}ms +
+ )} + {health.services.discord.detail && ( +
+ {health.services.discord.detail} +
+ )} +
+
+
Event Registry
+
+ {serviceStatusLabel(health.services.eventRegistry.status)} +
+
+ {health.services.eventRegistry.eventCount.toLocaleString()} events +
+
+ + )} +
+
+
+
+ ); +} diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 39163a4..694ce89 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -6,10 +6,12 @@ import { EventExplorerTable } from '../components/EventExplorerTable'; import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton'; import { PaginationControls } from '../components/PaginationControls'; import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; +import { NotificationHealthPanel } from '../components/NotificationHealthPanel'; import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi'; import { resolveIndexingHealthUrl } from '../services/indexingHealthApi'; +import { resolveNotificationHealthUrl } from '../services/notificationHealthApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; import { useWalletAccountSync } from '../hooks/useWalletAccountSync'; @@ -20,6 +22,8 @@ const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/ap const LISTENER_BASE_URL = API_URL.replace('/api/events', ''); const INDEXING_HEALTH_URL = import.meta.env.VITE_INDEXING_HEALTH_URL ?? resolveIndexingHealthUrl(API_URL); +const NOTIFICATION_HEALTH_URL = + import.meta.env.VITE_NOTIFICATION_HEALTH_URL ?? resolveNotificationHealthUrl(API_URL); function parsePageParam(search: string) { const params = new URLSearchParams(search); @@ -199,6 +203,7 @@ export function EventExplorerPage() { )} + diff --git a/dashboard/src/services/notificationHealthApi.ts b/dashboard/src/services/notificationHealthApi.ts new file mode 100644 index 0000000..dc177db --- /dev/null +++ b/dashboard/src/services/notificationHealthApi.ts @@ -0,0 +1,40 @@ +import type { + ScheduleStatsResponse, + HealthResponse, + NotificationAnalyticsSnapshot, +} from '../types/notificationHealth'; + +export async function fetchScheduleStats(apiUrl: string): Promise { + const response = await fetch(`${apiUrl}/api/schedule/stats`); + if (!response.ok) { + throw new Error(`Failed to fetch schedule stats: ${response.status}`); + } + return response.json() as Promise; +} + +export async function fetchHealth(apiUrl: string): Promise { + const response = await fetch(`${apiUrl}/health`); + if (!response.ok) { + throw new Error(`Failed to fetch health: ${response.status}`); + } + return response.json() as Promise; +} + +export async function fetchAnalytics(apiUrl: string): Promise { + const response = await fetch(`${apiUrl}/api/analytics`); + if (!response.ok) { + throw new Error(`Failed to fetch analytics: ${response.status}`); + } + return response.json() as Promise; +} + +export function resolveNotificationHealthUrl(eventsApiUrl: string): string { + try { + const url = new URL(eventsApiUrl); + url.pathname = ''; + url.search = ''; + return url.toString(); + } catch { + return 'http://localhost:8787'; + } +} diff --git a/dashboard/src/types/notificationHealth.ts b/dashboard/src/types/notificationHealth.ts new file mode 100644 index 0000000..b332932 --- /dev/null +++ b/dashboard/src/types/notificationHealth.ts @@ -0,0 +1,68 @@ +export interface ScheduleStatsResponse { + pending: number; + processing: number; + completed: number; + failed: number; + overdue: number; +} + +export interface HealthServiceStatus { + status: 'ok' | 'error' | 'not_configured'; + latencyMs?: number; + detail?: string; +} + +export interface HealthResponse { + status: 'ok' | 'degraded' | 'error'; + timestamp: string; + services: { + stellarRpc: HealthServiceStatus; + discord: HealthServiceStatus; + eventRegistry: { status: 'ok' | 'error' | 'not_configured'; eventCount: number }; + }; +} + +export interface NotificationAnalyticsSnapshot { + totalRecorded: number; + windowStart: number; + windowEnd: number; + overall: { + total: number; + success: number; + failure: number; + retry: number; + skipped: number; + successRate: number; + averageDurationMs: number; + }; + byType: Array<{ + notificationType: string; + total: number; + success: number; + failure: number; + successRate: number; + }>; + byContract: Array<{ + contractAddress: string; + total: number; + success: number; + failure: number; + successRate: number; + }>; + hourlyBuckets: Array<{ + bucketStart: number; + total: number; + success: number; + failure: number; + retry: number; + skipped: number; + averageDurationMs: number; + }>; + errorBreakdown: Record; +} + +export interface NotificationHealthData { + scheduleStats: ScheduleStatsResponse | null; + health: HealthResponse | null; + analytics: NotificationAnalyticsSnapshot | null; +}