diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 0316cca..cb75d6c 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -26,6 +26,7 @@ import { ToastProvider } from './context/ToastContext'; import { useTheme } from './hooks/useTheme'; import { DeliveryHeatmap } from './components/DeliveryHeatmap'; import { useEventStore } from './store/eventStore'; +import { SyncStatus } from './components/SyncStatus'; export function App() { const [tab, setTab] = useState('explorer'); @@ -100,6 +101,7 @@ export function App() { NotifyChain
+
diff --git a/dashboard/src/components/SyncStatus.test.tsx b/dashboard/src/components/SyncStatus.test.tsx new file mode 100644 index 0000000..9a075c6 --- /dev/null +++ b/dashboard/src/components/SyncStatus.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SyncStatus } from './SyncStatus'; +import { useEventStore } from '../store/eventStore'; + +describe('SyncStatus', () => { + it('renders last sync timestamp and error state', () => { + useEventStore.setState({ + lastSuccessfulSyncAt: Date.now(), + lastSyncFailureAt: Date.now(), + lastSyncError: 'Background refresh failed', + }); + + render(); + expect(screen.getByText(/Last sync:/)).toBeInTheDocument(); + expect(screen.getByText('refresh failed')).toBeInTheDocument(); + }); +}); + diff --git a/dashboard/src/components/SyncStatus.tsx b/dashboard/src/components/SyncStatus.tsx new file mode 100644 index 0000000..358002c --- /dev/null +++ b/dashboard/src/components/SyncStatus.tsx @@ -0,0 +1,22 @@ +import { useEventStore } from '../store/eventStore'; +import { formatTimestampShort } from '../utils/formatTime'; + +export function SyncStatus() { + const lastSuccessfulSyncAt = useEventStore((state) => state.lastSuccessfulSyncAt); + const lastSyncFailureAt = useEventStore((state) => state.lastSyncFailureAt); + const lastSyncError = useEventStore((state) => state.lastSyncError); + + if (!lastSuccessfulSyncAt && !lastSyncFailureAt) return null; + + const label = lastSuccessfulSyncAt ? formatTimestampShort(lastSuccessfulSyncAt) : '—'; + const isError = Boolean(lastSyncError); + + return ( +
+
+ ); +} + diff --git a/dashboard/src/index.css b/dashboard/src/index.css index db265a3..e473ed8 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -175,6 +175,32 @@ body { display: flex; justify-content: flex-end; padding: 8px 0 0; + gap: 12px; + align-items: center; +} + +.sync-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: #9aa0a6; + white-space: nowrap; +} + +.sync-status__dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #22c55e; +} + +.sync-status--error .sync-status__dot { + background: #f87171; +} + +.sync-status__error { + color: #f87171; } .app-nav { @@ -5777,6 +5803,10 @@ body.event-explorer--resizing { color: #202124; } +[data-theme="light"] .sync-status { + color: #5f6368; +} + [data-theme="light"] .app__hamburger { border-color: rgba(0, 0, 0, 0.15); color: #202124; diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index c83c0e5..8ded18f 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -51,6 +51,8 @@ export function EventExplorerPage() { const setEvents = useEventStore((state) => state.setEvents); const setLoading = useEventStore((state) => state.setLoading); const setError = useEventStore((state) => state.setError); + const markSyncSuccess = useEventStore((state) => state.markSyncSuccess); + const markSyncFailure = useEventStore((state) => state.markSyncFailure); const setSearch = useEventStore((state) => state.setSearch); const setContractFilter = useEventStore((state) => state.setContractFilter); const setEventTypeFilter = useEventStore((state) => state.setEventTypeFilter); @@ -87,11 +89,13 @@ export function EventExplorerPage() { const remoteEvents = await fetchEvents(API_URL); if (!cancelled) { setEvents(remoteEvents); + markSyncSuccess(); } } catch { if (!cancelled) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Listener API unavailable — showing mock events for demo.'); + markSyncFailure('Initial sync failed'); } } finally { if (!cancelled) { @@ -121,10 +125,12 @@ export function EventExplorerPage() { const remoteEvents = await fetchEvents(API_URL); if (!cancelled) { setEvents(remoteEvents); + markSyncSuccess(); } } catch { - // Silently ignore polling errors — the error banner is reserved for - // the initial load failure so background polls don't disrupt the user. + if (!cancelled) { + markSyncFailure('Background refresh failed'); + } } }, POLL_INTERVAL_MS); @@ -145,10 +151,12 @@ export function EventExplorerPage() { fetchEvents(API_URL) .then((remoteEvents) => { setEvents(remoteEvents); + markSyncSuccess(); }) .catch(() => { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Listener API unavailable — showing mock events for demo.'); + markSyncFailure('Wallet refresh failed'); }) .finally(() => { setLoading(false); @@ -207,13 +215,15 @@ export function EventExplorerPage() { try { const remoteEvents = await fetchEvents(API_URL); setEvents(remoteEvents); + markSyncSuccess(); } catch { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Retry failed — still using demo event data.'); + markSyncFailure('Manual refresh failed'); } finally { setLoading(false); } - }, [setError, setEvents, setLoading]); + }, [markSyncFailure, markSyncSuccess, setError, setEvents, setLoading]); const handleSelectEvent = useCallback((event: BlockchainEvent) => { setSelectedNotification(event); diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx index 96eb890..b2572df 100644 --- a/dashboard/src/pages/EventsPage.tsx +++ b/dashboard/src/pages/EventsPage.tsx @@ -18,6 +18,8 @@ export function EventsPage() { const setEvents = useEventStore((state) => state.setEvents); const setLoading = useEventStore((state) => state.setLoading); const setError = useEventStore((state) => state.setError); + const markSyncSuccess = useEventStore((state) => state.markSyncSuccess); + const markSyncFailure = useEventStore((state) => state.markSyncFailure); // Re-fetch whenever lastFetchedAt is reset to 0 (via invalidateEvents()) so // that a successful blockchain status-change transaction is reflected on the // next render cycle without requiring a full hard refresh. @@ -46,11 +48,13 @@ export function EventsPage() { const remoteEvents = await fetchEvents(API_URL); if (!cancelled) { setEvents(remoteEvents); + markSyncSuccess(); } } catch { if (!cancelled) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Listener API unavailable — showing mock events for demo.'); + markSyncFailure('Initial sync failed'); } } finally { if (!cancelled) { @@ -66,9 +70,12 @@ export function EventsPage() { const remoteEvents = await fetchEvents(API_URL); if (!cancelled) { setEvents(remoteEvents); + markSyncSuccess(); } } catch { - // Silently ignore background poll errors. + if (!cancelled) { + markSyncFailure('Background refresh failed'); + } } }, POLL_INTERVAL_MS); @@ -76,7 +83,7 @@ export function EventsPage() { cancelled = true; clearInterval(intervalId); }; - }, [lastFetchedAt, setEvents, setError, setLoading]); + }, [lastFetchedAt, markSyncFailure, markSyncSuccess, setEvents, setError, setLoading]); return (
@@ -99,4 +106,4 @@ export function EventsPage() { {isLoading ? : }
); -} \ No newline at end of file +} diff --git a/dashboard/src/store/eventStore.ts b/dashboard/src/store/eventStore.ts index 8f58bbe..7bbe37b 100644 --- a/dashboard/src/store/eventStore.ts +++ b/dashboard/src/store/eventStore.ts @@ -21,6 +21,9 @@ interface EventStoreState { * after it completes. */ lastFetchedAt: number; + lastSuccessfulSyncAt: number | null; + lastSyncFailureAt: number | null; + lastSyncError: string | null; setEvents: (events: BlockchainEvent[]) => void; appendEvents: (events: BlockchainEvent[]) => void; setSearch: (search: string) => void; @@ -32,6 +35,8 @@ interface EventStoreState { setTxHashFilter: (txHash: string) => void; setLoading: (isLoading: boolean) => void; setError: (error: string | null) => void; + markSyncSuccess: () => void; + markSyncFailure: (error: string) => void; /** * Patch the `notificationStatus` of every cached event whose `eventId` * matches `targetEventId`. Call this immediately after a successful @@ -77,6 +82,9 @@ export const useEventStore = create((set) => ({ isLoading: false, error: null, lastFetchedAt: 0, + lastSuccessfulSyncAt: null, + lastSyncFailureAt: null, + lastSyncError: null, setEvents: (events) => set({ events: dedupeEventsById(events), lastFetchedAt: Date.now() }), appendEvents: (events) => set((state) => ({ @@ -99,6 +107,8 @@ export const useEventStore = create((set) => ({ set((state) => ({ filters: { ...state.filters, txHash } })), setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), + markSyncSuccess: () => set({ lastSuccessfulSyncAt: Date.now(), lastSyncFailureAt: null, lastSyncError: null }), + markSyncFailure: (error) => set({ lastSyncFailureAt: Date.now(), lastSyncError: error }), updateEventStatus: (targetEventId, status) => set((state) => ({ events: state.events.map((event) =>