diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 5762fb0..2443663 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,9 +1,9 @@ -import { EventsPage } from './pages/EventsPage'; +import { EventExplorerPage } from './pages/EventExplorerPage'; export function App() { return (
- +
); } diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx new file mode 100644 index 0000000..8f4768e --- /dev/null +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -0,0 +1,86 @@ +import type { BlockchainEvent } from '../types/event'; +import { formatTimestamp } from '../utils/formatTime'; + +const EVENT_KIND_STYLES: Record = { + contract: 'event-explorer__badge--blue', + system: 'event-explorer__badge--purple', + debug: 'event-explorer__badge--default', +}; + +const EVENT_KIND_LABELS: Record = { + contract: 'Contract', + system: 'System', + debug: 'Debug', +}; + +function shortenAddress(address: string) { + if (address.length <= 14) { + return address; + } + + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function getEventKindClass(type: string) { + return EVENT_KIND_STYLES[type.toLowerCase()] ?? EVENT_KIND_STYLES.debug; +} + +function getEventKindLabel(type: string) { + return EVENT_KIND_LABELS[type.toLowerCase()] ?? 'Unknown'; +} + +interface EventExplorerCardProps { + event: BlockchainEvent; + onCopyContract: (contractAddress: string) => void; + isCopied: boolean; +} + +export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExplorerCardProps) { + const label = event.eventName ?? event.type; + const badgeClass = getEventKindClass(event.type); + const kindLabel = getEventKindLabel(event.type); + + return ( +
+
+
+

+ {shortenAddress(event.contractAddress)} +

+ +
+
+ +
+

{label}

+
+ +
+ {kindLabel} +
+ +
+ +
+ +
+ {event.ledger.toLocaleString()} +
+ +
+ + {event.txHash ? shortenAddress(event.txHash) : '—'} + +
+
+ ); +} diff --git a/dashboard/src/components/EventExplorerSkeleton.tsx b/dashboard/src/components/EventExplorerSkeleton.tsx new file mode 100644 index 0000000..dd71d27 --- /dev/null +++ b/dashboard/src/components/EventExplorerSkeleton.tsx @@ -0,0 +1,22 @@ +interface EventExplorerSkeletonProps { + rows?: number; +} + +export function EventExplorerSkeleton({ rows = 6 }: EventExplorerSkeletonProps) { + return ( +
+
+ {Array.from({ length: rows }).map((_, rowIndex) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/dashboard/src/components/EventExplorerTable.tsx b/dashboard/src/components/EventExplorerTable.tsx new file mode 100644 index 0000000..161ab05 --- /dev/null +++ b/dashboard/src/components/EventExplorerTable.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import type { BlockchainEvent } from '../types/event'; +import { EventExplorerCard } from './EventExplorerCard'; + +interface EventExplorerTableProps { + events: BlockchainEvent[]; +} + +export function EventExplorerTable({ events }: EventExplorerTableProps) { + const [copiedAddress, setCopiedAddress] = useState(null); + + async function syncCopyText(text: string) { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text); + } + + const fallback = document.createElement('textarea'); + fallback.value = text; + fallback.setAttribute('readonly', ''); + fallback.style.position = 'absolute'; + fallback.style.left = '-9999px'; + document.body.appendChild(fallback); + fallback.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(fallback); + + if (!successful) { + throw new Error('Clipboard copy failed.'); + } + } + + const handleCopyContract = async (address: string) => { + try { + await syncCopyText(address); + setCopiedAddress(address); + window.setTimeout(() => setCopiedAddress(null), 1800); + } catch { + setCopiedAddress(null); + } + }; + + return ( +
+
+
Contract
+
Event
+
Kind
+
Received
+
Ledger
+
Transaction
+
+ +
+ {events.map((event) => ( + + ))} +
+
+ ); +} diff --git a/dashboard/src/components/PaginationControls.tsx b/dashboard/src/components/PaginationControls.tsx new file mode 100644 index 0000000..2781f10 --- /dev/null +++ b/dashboard/src/components/PaginationControls.tsx @@ -0,0 +1,74 @@ +interface PaginationControlsProps { + page: number; + pageCount: number; + limit: number; + totalCount: number; + onPageChange: (page: number) => void; + onLimitChange: (limit: number) => void; +} + +const LIMIT_OPTIONS = [8, 12, 20, 40]; + +export function PaginationControls({ + page, + pageCount, + limit, + totalCount, + onPageChange, + onLimitChange, +}: PaginationControlsProps) { + const handlePrevious = () => { + onPageChange(Math.max(1, page - 1)); + }; + + const handleNext = () => { + onPageChange(Math.min(pageCount, page + 1)); + }; + + return ( +
+
+ + Page {page} of {pageCount} + + {totalCount.toLocaleString()} total events +
+ +
+ + + + + + +
+
+ ); +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 94d3ba7..f3b7ac7 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -443,4 +443,318 @@ body { font-size: 0.8rem; max-width: 220px; text-align: right; -} \ No newline at end of file +} + +/* ─── Event Explorer ───────────────────────────────────────────────────── */ + +.event-explorer-page { + display: grid; + gap: 20px; +} + +.event-explorer__header { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-start; + padding: 24px 0 12px; +} + +.event-explorer__eyebrow { + margin: 0 0 10px; + font-size: 0.85rem; + color: #60a5fa; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.event-explorer__header h1 { + margin: 0 0 10px; + font-size: clamp(1.75rem, 2.5vw, 2.5rem); +} + +.event-explorer__lead { + margin: 0; + max-width: 700px; + color: #cbd5e1; + line-height: 1.75; +} + +.event-explorer__error-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 18px; + border: 1px solid rgba(248, 113, 113, 0.18); + border-radius: 14px; + background: rgba(248, 113, 113, 0.08); + color: #f8c0c0; +} + +.event-explorer__retry-button { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + padding: 10px 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.event-explorer__retry-button:hover, +.event-explorer__retry-button:focus-visible { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.24); + outline: none; +} + +.event-explorer__status-row { + display: flex; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; + align-items: center; + color: #9aa0a6; +} + +.event-explorer__summary, +.event-explorer__loading-note { + margin: 0; +} + +.event-explorer__table-wrapper { + display: grid; + gap: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); +} + +.event-explorer__table-header { + display: grid; + grid-template-columns: 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr; + gap: 16px; + padding: 16px 18px; + font-size: 0.85rem; + color: #9aa0a6; + background: rgba(255, 255, 255, 0.03); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.event-explorer__table-body { + display: grid; +} + +.event-explorer__row { + display: grid; + grid-template-columns: 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr; + gap: 16px; + padding: 18px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + align-items: center; +} + +.event-explorer__row--loading { + gap: 16px; + min-height: 80px; +} + +.event-explorer__cell { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.event-explorer__contract { + margin: 0; + font-family: 'Courier New', Courier, monospace; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-explorer__event-name { + margin: 0; + font-weight: 600; + color: #f8fafc; +} + +.event-explorer__badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.event-explorer__badge--blue { + color: #93c5fd; + background: rgba(96, 165, 250, 0.14); +} + +.event-explorer__badge--purple { + color: #d8b4fe; + background: rgba(167, 139, 250, 0.14); +} + +.event-explorer__badge--default { + color: #e2e8f0; + background: rgba(255, 255, 255, 0.08); +} + +.event-explorer__copy-button { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + color: #cbd5e1; + font-size: 0.8rem; + padding: 7px 10px; + width: fit-content; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.event-explorer__copy-button:hover, +.event-explorer__copy-button:focus-visible { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); + outline: none; +} + +.event-explorer__empty-state { + text-align: center; + padding: 48px 24px; + border: 1px dashed rgba(255, 255, 255, 0.16); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + color: #cbd5e1; +} + +.event-explorer__empty-state h2 { + margin: 0 0 10px; + font-size: 1.4rem; +} + +.pagination-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 18px 0 2px; + flex-wrap: wrap; +} + +.pagination-controls__summary { + color: #9aa0a6; + font-size: 0.9rem; + display: flex; + gap: 14px; + flex-wrap: wrap; +} + +.pagination-controls__actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.pagination-controls__button, +.pagination-controls__select { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.05); + color: inherit; +} + +.pagination-controls__button { + font-weight: 600; + cursor: pointer; +} + +.pagination-controls__button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-controls__select { + min-width: 90px; +} + +.pagination-controls__label { + font-size: 0.85rem; + color: #9aa0a6; +} + +@media (max-width: 900px) { + .event-explorer__table-header, + .event-explorer__row { + grid-template-columns: 1.6fr 1.2fr 1fr; + } + + .event-explorer__table-header > div:nth-child(4), + .event-explorer__table-header > div:nth-child(5), + .event-explorer__table-header > div:nth-child(6), + .event-explorer__row > .event-explorer__cell:nth-child(4), + .event-explorer__row > .event-explorer__cell:nth-child(5), + .event-explorer__row > .event-explorer__cell:nth-child(6) { + display: none; + } +} + +@media (max-width: 680px) { + .event-explorer__header { + flex-direction: column; + align-items: stretch; + } + + .event-explorer__table-wrapper { + border-radius: 14px; + } + + .event-explorer__table-body, + .event-explorer__row { + display: block; + } + + .event-explorer__row { + padding: 18px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + } + + .event-explorer__cell { + flex-direction: row; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + width: 100%; + } + + .event-explorer__cell:last-child { + margin-bottom: 0; + } + + .event-explorer__cell[data-label]::before { + content: attr(data-label); + color: #9aa0a6; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .event-explorer__table-header { + display: none; + } +} diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx new file mode 100644 index 0000000..724938b --- /dev/null +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { EventFiltersBar } from '../components/EventFiltersBar'; +import { WalletConnectButton } from '../components/WalletConnectButton'; +import { EventExplorerTable } from '../components/EventExplorerTable'; +import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton'; +import { PaginationControls } from '../components/PaginationControls'; +import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; +import { useEventStore } from '../store/eventStore'; +import { fetchEvents } from '../services/eventsApi'; +import { generateMockEvents } from '../utils/eventData'; +import { restoreWalletSession } from '../services/wallet'; + +const DEFAULT_EVENT_COUNT = 5000; +const DEFAULT_LIMIT = 12; +const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events'; + +function parsePageParam(search: string) { + const params = new URLSearchParams(search); + const value = Number(params.get('page')); + return Number.isInteger(value) && value > 0 ? value : 1; +} + +function parseLimitParam(search: string) { + const params = new URLSearchParams(search); + const value = Number(params.get('limit')); + return Number.isInteger(value) && value > 0 ? value : DEFAULT_LIMIT; +} + +export function EventExplorerPage() { + const initialSearch = typeof window !== 'undefined' ? window.location.search : ''; + const [page, setPage] = useState(() => parsePageParam(initialSearch)); + const [limit, setLimit] = useState(() => parseLimitParam(initialSearch)); + + const setEvents = useEventStore((state) => state.setEvents); + const setLoading = useEventStore((state) => state.setLoading); + const setError = useEventStore((state) => state.setError); + const { isLoading, error } = useEventLoadingState(); + const filters = useEventFilters(); + const filteredEvents = useFilteredEvents(); + + useEffect(() => { + restoreWalletSession(); + }, []); + + useEffect(() => { + let cancelled = false; + + async function loadEvents() { + setLoading(true); + setError(null); + + try { + const remoteEvents = await fetchEvents(API_URL); + if (!cancelled) { + setEvents(remoteEvents); + } + } catch { + if (!cancelled) { + setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); + setError('Listener API unavailable — showing mock events for demo.'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + loadEvents(); + + return () => { + cancelled = true; + }; + }, [setEvents, setError, setLoading]); + + const pageCount = useMemo( + () => Math.max(1, Math.ceil(filteredEvents.length / limit)), + [filteredEvents.length, limit] + ); + + useEffect(() => { + if (page > pageCount) { + setPage(pageCount); + } + }, [pageCount, page]); + + useEffect(() => { + setPage(1); + }, [filters.search, filters.contractAddress, filters.eventType]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const params = new URLSearchParams(window.location.search); + params.set('page', String(page)); + params.set('limit', String(limit)); + + window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`); + }, [page, limit]); + + const currentPageEvents = useMemo(() => { + const startIndex = (page - 1) * limit; + return filteredEvents.slice(startIndex, startIndex + limit); + }, [filteredEvents, page, limit]); + + const fromIndex = filteredEvents.length === 0 ? 0 : (page - 1) * limit + 1; + const toIndex = Math.min(filteredEvents.length, page * limit); + + const handleRetry = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const remoteEvents = await fetchEvents(API_URL); + setEvents(remoteEvents); + } catch { + setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); + setError('Retry failed — still using demo event data.'); + } finally { + setLoading(false); + } + }, [setError, setEvents, setLoading]); + + return ( +
+
+
+

Event Explorer

+

Smart Contract Event Log

+

+ Browse Soroban contract events across registered contracts with filters, + pagination, and copy-to-clipboard contract metadata. +

+
+ +
+ + + + {error && ( +
+
+ Error: {error} +
+ +
+ )} + +
+

+ Showing {fromIndex.toLocaleString()}–{toIndex.toLocaleString()} of{' '} + {filteredEvents.length.toLocaleString()} events +

+ {isLoading &&

Loading events…

} +
+ + {isLoading ? ( + + ) : currentPageEvents.length > 0 ? ( + + ) : ( +
+

No events found

+

+ Update the search, event type, or contract filter to uncover matching Soroban + contract events. +

+
+ )} + + +
+ ); +}