Skip to content
Merged
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
2 changes: 2 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tab>('explorer');
Expand Down Expand Up @@ -100,6 +101,7 @@ export function App() {
<span className="app__brand">NotifyChain</span>

<div className="app__theme-bar">
<SyncStatus />
<ThemeToggle theme={theme} onToggle={toggleTheme} />
</div>
</div>
Expand Down
20 changes: 20 additions & 0 deletions dashboard/src/components/SyncStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SyncStatus />);
expect(screen.getByText(/Last sync:/)).toBeInTheDocument();
expect(screen.getByText('refresh failed')).toBeInTheDocument();
});
});

22 changes: 22 additions & 0 deletions dashboard/src/components/SyncStatus.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`sync-status${isError ? ' sync-status--error' : ''}`} title={lastSyncError ?? undefined}>
<span className="sync-status__dot" aria-hidden="true" />
<span>Last sync: {label}</span>
{isError && <span className="sync-status__error">refresh failed</span>}
</div>
);
}

30 changes: 30 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions dashboard/src/pages/EventsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -66,17 +70,20 @@ 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);

return () => {
cancelled = true;
clearInterval(intervalId);
};
}, [lastFetchedAt, setEvents, setError, setLoading]);
}, [lastFetchedAt, markSyncFailure, markSyncSuccess, setEvents, setError, setLoading]);

return (
<main className="events-page">
Expand All @@ -99,4 +106,4 @@ export function EventsPage() {
{isLoading ? <EventsListSkeleton /> : <EventListPanel />}
</main>
);
}
}
10 changes: 10 additions & 0 deletions dashboard/src/store/eventStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -77,6 +82,9 @@ export const useEventStore = create<EventStoreState>((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) => ({
Expand All @@ -99,6 +107,8 @@ export const useEventStore = create<EventStoreState>((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) =>
Expand Down
Loading