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/index.css b/dashboard/src/index.css index 1ff6939..5300698 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -1517,3 +1517,201 @@ body { text-align: center; } } + +/* ─── Notification Health Panel ─────────────────────────────────────────── */ + +.notification-health { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 18px 18px 16px; + background: rgba(255, 255, 255, 0.02); + display: grid; + gap: 14px; +} + +.notification-health__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + +.notification-health__eyebrow { + margin: 0 0 6px; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #60a5fa; +} + +.notification-health__title { + margin: 0; + font-size: 1.05rem; +} + +.notification-health__meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.notification-health__status { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.notification-health__status--ok { + color: #34d399; + background: rgba(52, 211, 153, 0.14); +} + +.notification-health__status--degraded { + color: #f4b400; + background: rgba(244, 180, 0, 0.14); +} + +.notification-health__status--error { + color: #f87171; + background: rgba(248, 113, 113, 0.14); +} + +.notification-health__status--unknown { + color: #9aa0a6; + background: rgba(255, 255, 255, 0.08); +} + +.notification-health__updated { + color: #9aa0a6; + font-size: 0.85rem; +} + +.notification-health__grid { + display: grid; + gap: 14px; +} + +.notification-health__card { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 14px 14px 12px; + background: rgba(255, 255, 255, 0.02); +} + +.notification-health__card--full { + grid-column: 1 / -1; +} + +.notification-health__card-title { + margin: 0 0 12px; + font-size: 0.95rem; + font-weight: 600; + color: #cbd5e1; +} + +.notification-health__metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.notification-health__metric { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 10px 10px 8px; + background: rgba(255, 255, 255, 0.02); +} + +.notification-health__metric dt { + font-size: 0.75rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; +} + +.notification-health__metric dd { + margin: 0; + font-size: 1rem; + font-weight: 650; + font-family: 'Courier New', Courier, monospace; +} + +.notification-health__services-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; +} + +.notification-health__service { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 10px 10px 8px; + background: rgba(255, 255, 255, 0.02); +} + +.notification-health__service-name { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 6px; + color: #e2e8f0; +} + +.notification-health__service-status { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} + +.notification-health__service-latency { + font-size: 0.8rem; + color: #9aa0a6; + font-family: 'Courier New', Courier, monospace; +} + +.notification-health__service-detail { + font-size: 0.8rem; + color: #9aa0a6; + margin-top: 4px; +} + +.notification-health__service--ok { + border-color: rgba(52, 211, 153, 0.2); +} + +.notification-health__service--ok .notification-health__service-status { + color: #34d399; +} + +.notification-health__service--error { + border-color: rgba(248, 113, 113, 0.2); +} + +.notification-health__service--error .notification-health__service-status { + color: #f87171; +} + +.notification-health__service--not-configured { + border-color: rgba(148, 163, 184, 0.2); +} + +.notification-health__service--not-configured .notification-health__service-status { + color: #9aa0a6; +} + +.notification-health__error { + margin: 0; + color: #f87171; + font-size: 0.9rem; +} diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 7d9e42c..9db3472 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -6,11 +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 { fetchEvents } from '../services/eventsApi'; import { resolveIndexingHealthUrl } from '../services/indexingHealthApi'; +import { resolveNotificationHealthUrl } from '../services/notificationHealthApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; @@ -20,6 +21,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); @@ -179,6 +182,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; +}