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
299 changes: 127 additions & 172 deletions dashboard/src/App.tsx

Large diffs are not rendered by default.

188 changes: 144 additions & 44 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,56 @@ import { EmptyState } from './EmptyState';
// Helper to get icon/color based on activity type
const getActivityTypeStyle = (type: ActivityType) => {
const styles: Record<ActivityType, { color: string; icon: string; bg: string }> = {
notification_sent: { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' },
notification_failed: { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' },
notification_retried: { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' },
contract_event_received: { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' },
preference_updated: { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' },
template_created: { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' },
template_updated: { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' },
webhook_received: { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' },
notification_sent: {
color: '#34d399',
icon: '✓',
bg: 'rgba(52, 211, 153, 0.12)',
},
notification_failed: {
color: '#f87171',
icon: '✕',
bg: 'rgba(248, 113, 113, 0.12)',
},
notification_retried: {
color: '#f4b400',
icon: '↻',
bg: 'rgba(244, 180, 0, 0.12)',
},
contract_event_received: {
color: '#60a5fa',
icon: '📡',
bg: 'rgba(96, 165, 250, 0.12)',
},
preference_updated: {
color: '#a78bfa',
icon: '⚙',
bg: 'rgba(167, 139, 250, 0.12)',
},
template_created: {
color: '#38bdf8',
icon: '📄',
bg: 'rgba(56, 189, 248, 0.12)',
},
template_updated: {
color: '#22d3ee',
icon: '📝',
bg: 'rgba(34, 211, 238, 0.12)',
},
webhook_received: {
color: '#fb923c',
icon: '🔗',
bg: 'rgba(251, 146, 60, 0.12)',
},
};

return styles[type] || styles['contract_event_received'];
};

// Individual activity event card
const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
const style = getActivityTypeStyle(event.type);
const timestamp = parseToDate(event.timestamp);

return (
<div
className={`activity-event ${!event.read ? 'activity-event--unread' : ''}`}
Expand All @@ -38,11 +72,16 @@ const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
>
{style.icon}
</div>

<div className="activity-event__content">
<div className="activity-event__header">
<span className="activity-event__type" style={{ color: style.color }}>
<span
className="activity-event__type"
style={{ color: style.color }}
>
{event.type.replace(/_/g, ' ')}
</span>

<time
className="activity-event__time"
dateTime={timestamp?.toISOString()}
Expand All @@ -51,23 +90,39 @@ const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
{formatRelativeTimestamp(event.timestamp)}
</time>
</div>

<p className="activity-event__message">{event.message}</p>

{Object.keys(event.metadata).length > 0 && (
<div className="activity-event__metadata">
{Object.entries(event.metadata).map(
([key, value]) =>
value !== undefined &&
value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
<span
key={key}
className="activity-event__metadata-item"
>
<span className="activity-event__metadata-key">
{key}:
</span>

<span className="activity-event__metadata-value">
{String(value)}
</span>
</span>
),
)}
</div>
)}
</div>
{!event.read && <div className="activity-event__unread-indicator" aria-hidden="true" />}

{!event.read && (
<div
className="activity-event__unread-indicator"
aria-hidden="true"
/>
)}
</div>
);
};
Expand All @@ -76,10 +131,22 @@ const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
const ActivitySkeleton = () => (
<div className="activity-event activity-event--skeleton">
<div className="activity-event__icon activity-event__icon--skeleton" />

<div className="activity-event__content activity-event__content--skeleton">
<div className="activity-event__skeleton-line" style={{ width: '40%' }} />
<div className="activity-event__skeleton-line" style={{ width: '70%' }} />
<div className="activity-event__skeleton-line" style={{ width: '50%' }} />
<div
className="activity-event__skeleton-line"
style={{ width: '40%' }}
/>

<div
className="activity-event__skeleton-line"
style={{ width: '70%' }}
/>

<div
className="activity-event__skeleton-line"
style={{ width: '50%' }}
/>
</div>
</div>
);
Expand All @@ -97,35 +164,41 @@ export function ActivityFeed() {
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [realTimeEnabled, setRealTimeEnabled] = useState(true);

// Real-time events are prepended; track them separately so pagination totals
// stay accurate without mixing them into the paginated data.
const [liveEvents, setLiveEvents] = useState<ActivityEvent[]>([]);

const pageCount = Math.max(1, Math.ceil(total / pageSize));

// Load a specific page of events
const loadEvents = useCallback(async (pageNum: number, size: number) => {
setLoading(true);
setError(null);
const loadEvents = useCallback(
async (pageNum: number, size: number) => {
setLoading(true);
setError(null);

try {
try {
const data = await fetchActivityFeed(pageNum, size);
setEvents(data.events);
setTotal(data.total);
try {
const data = await fetchActivityFeed(pageNum, size);

setEvents(data.events);
setTotal(data.total);
} catch {
// Fallback to stable mock data
const start = (pageNum - 1) * size;
const end = start + size;

setEvents(ALL_MOCK_EVENTS.slice(start, end));
setTotal(ALL_MOCK_EVENTS.length);
}
} catch {
// Fallback to stable mock data
const start = (pageNum - 1) * size;
const end = start + size;
setEvents(ALL_MOCK_EVENTS.slice(start, end));
setTotal(ALL_MOCK_EVENTS.length);
setError('Failed to load activity feed');
} finally {
setLoading(false);
}
} catch {
setError('Failed to load activity feed');
} finally {
setLoading(false);
}
}, []);
},
[],
);

// Reload whenever page or pageSize changes
useEffect(() => {
Expand All @@ -138,6 +211,7 @@ export function ActivityFeed() {

const interval = setInterval(() => {
const [newEvent] = generateMockActivityEvents(1);

setLiveEvents((prev) => [newEvent, ...prev]);
setTotal((prev) => prev + 1);
}, 15000);
Expand All @@ -147,6 +221,7 @@ export function ActivityFeed() {

const handlePageChange = (newPage: number) => {
setPage(newPage);

// Clear live events on navigation so the count stays consistent
setLiveEvents([]);
};
Expand All @@ -164,30 +239,46 @@ export function ActivityFeed() {
setLiveEvents([]);
setTotal(0);
setPage(1);

loadEvents(1, pageSize);
});

// Events shown: live prepended events (only on page 1) + paginated events
const displayedEvents = page === 1 ? [...liveEvents, ...events] : events;
const displayedEvents =
page === 1 ? [...liveEvents, ...events] : events;

return (
<section className="activity-feed" aria-labelledby="activity-feed-title">
<section
className="activity-feed"
aria-labelledby="activity-feed-title"
>
<div className="activity-feed__header">
<div>
<h2 id="activity-feed-title" className="activity-feed__title">
<h2
id="activity-feed-title"
className="activity-feed__title"
>
Activity Feed
</h2>

<p className="activity-feed__subtitle">
Recent actions and system events ({total.toLocaleString()} total)
</p>
</div>

<button
type="button"
className={`activity-feed__toggle-realtime ${realTimeEnabled ? 'activity-feed__toggle-realtime--active' : ''}`}
className={`activity-feed__toggle-realtime ${
realTimeEnabled
? 'activity-feed__toggle-realtime--active'
: ''
}`}
onClick={() => setRealTimeEnabled(!realTimeEnabled)}
aria-pressed={realTimeEnabled}
aria-label={
realTimeEnabled ? 'Pause live activity updates' : 'Resume live activity updates'
realTimeEnabled
? 'Pause live activity updates'
: 'Resume live activity updates'
}
>
{realTimeEnabled ? '🔴 Live' : '⏸ Paused'}
Expand All @@ -197,6 +288,7 @@ export function ActivityFeed() {
{error && (
<div className="activity-feed__error" role="alert">
<p>{error}</p>

<button
type="button"
className="activity-feed__retry"
Expand All @@ -207,18 +299,26 @@ export function ActivityFeed() {
</div>
)}

<div className="activity-feed__list" role="list" aria-label="Activity events">
<div
className="activity-feed__list"
role="list"
aria-label="Activity events"
>
{loading && events.length === 0 ? (
Array.from({ length: 5 }).map((_, i) => <ActivitySkeleton key={i} />)
Array.from({ length: 5 }).map((_, i) => (
<ActivitySkeleton key={i} />
))
) : displayedEvents.length === 0 ? (
<EmptyState
size="compact"
title="No activity yet"
message="Actions and system events will show up here as they happen."
message="System events, notification deliveries, and contract activity will appear here as they occur."
className="empty-state--compact"
/>
) : (
displayedEvents.map((event) => <ActivityEventCard key={event.id} event={event} />)
displayedEvents.map((event) => (
<ActivityEventCard key={event.id} event={event} />
))
)}
</div>

Expand All @@ -232,4 +332,4 @@ export function ActivityFeed() {
/>
</section>
);
}
}
Loading
Loading