diff --git a/app/api/v1/events/route.ts b/app/api/v1/events/route.ts new file mode 100644 index 0000000..c56a68d --- /dev/null +++ b/app/api/v1/events/route.ts @@ -0,0 +1,126 @@ +/** + * GET /api/v1/events + * + * Server-side filtered and paginated event listing. + * + * Query params: + * contractId — filter by Soroban contract address + * eventType — filter by event topic/type (case-insensitive contains) + * network — "testnet" | "mainnet" (informational, stored contextually) + * page — 1-indexed page number (default: 1) + * limit — page size (default: 20, max: 100) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { MOCK_RAW_EVENTS } from "@/lib/mock-data"; + +export async function GET(req: NextRequest): Promise { + const { searchParams } = req.nextUrl; + + const contractId = searchParams.get("contractId") || undefined; + const eventType = searchParams.get("eventType") || undefined; + const network = searchParams.get("network") || "testnet"; + + const rawPage = parseInt(searchParams.get("page") || "1", 10); + const rawLimit = parseInt(searchParams.get("limit") || "20", 10); + const page = Math.max(1, isNaN(rawPage) ? 1 : rawPage); + const limit = Math.min(100, Math.max(1, isNaN(rawLimit) ? 20 : rawLimit)); + const skip = (page - 1) * limit; + + // Build Prisma where clause + const where: Record = {}; + if (contractId) { + where.contractId = contractId; + } + if (eventType) { + where.eventType = { + contains: eventType, + mode: "insensitive", + }; + } + + try { + const [totalCount, events] = await Promise.all([ + db.event.count({ where }), + db.event.findMany({ + where, + orderBy: { timestamp: "desc" }, + skip, + take: limit, + }), + ]); + + // Map database Event model to the client-facing RawEvent shape + const formattedEvents = events.map((ev) => ({ + id: ev.id, + contractId: ev.contractId, + topics: (ev.topics as string[]) || [], + data: ev.data, + ledger: ev.ledger, + timestamp: ev.timestamp, + txHash: ev.txHash, + // Pass through translated fields so the client can display them + description: ev.description, + status: ev.status, + eventType: ev.eventType, + blueprintName: ev.blueprintName, + })); + + const totalPages = Math.max(1, Math.ceil(totalCount / limit)); + + return NextResponse.json({ + events: formattedEvents, + pagination: { + total: totalCount, + page, + limit, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + meta: { network }, + }); + } catch (error: unknown) { + console.error("Failed to query database events, falling back to mock data:", error); + + // ── Fallback: use in-memory MOCK_RAW_EVENTS when DB is unavailable ── + let filtered = [...MOCK_RAW_EVENTS]; + + if (contractId) { + filtered = filtered.filter((e) => e.contractId === contractId); + } + if (eventType) { + filtered = filtered.filter((e) => { + const firstTopic = e.topics[0] || ""; + const name = firstTopic.includes("74726e73") + ? "Transfer" + : firstTopic.includes("6d696e74") + ? "Mint" + : firstTopic.includes("6275726e") + ? "Burn" + : firstTopic.includes("7377617073") + ? "Swap" + : "Unknown"; + return name.toLowerCase().includes(eventType.toLowerCase()); + }); + } + + const totalCount = filtered.length; + const paginated = filtered.slice(skip, skip + limit); + const totalPages = Math.max(1, Math.ceil(totalCount / limit)); + + return NextResponse.json({ + events: paginated, + pagination: { + total: totalCount, + page, + limit, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + meta: { network, fallback: true }, + }); + } +} diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx index 2014c24..1ab2019 100644 --- a/app/dashboard/DashboardClient.tsx +++ b/app/dashboard/DashboardClient.tsx @@ -1,8 +1,6 @@ "use client"; import { useState, useCallback, useEffect, useMemo } from "react"; -import { AlertCircle, BookOpen, ArrowRight, Radio, PauseCircle, PlayCircle, Upload, FileJson, Trash2 } from "lucide-react"; -import { SearchBar } from "@/components/dashboard/SearchBar"; import { AlertCircle, BookOpen, @@ -15,6 +13,8 @@ import { Trash2, Download, Star, + ChevronLeft, + ChevronRight, } from "lucide-react"; import { FilterBuilder } from "@/components/dashboard/FilterBuilder"; import { EventFeedTable } from "@/components/dashboard/EventFeedTable"; @@ -28,39 +28,60 @@ import { useLanguage } from "@/lib/hooks/useLanguage"; import { useNetwork } from "@/lib/hooks/useNetwork"; import { useDashboardPrefs } from "@/lib/hooks/useDashboardPrefs"; import { useEventFilters } from "@/lib/hooks/useEventFilters"; -import { getMockEventsForContract, MOCK_RAW_EVENTS } from "@/lib/mock-data"; +import { MOCK_RAW_EVENTS } from "@/lib/mock-data"; import { buildCustomBlueprints, loadCustomAbis, removeCustomAbi, saveCustomAbi, } from "@/lib/translator/custom-abi"; -import { getMockEventsForContract, MOCK_RAW_EVENTS } from "@/lib/mock-data"; -import { useLiveFeed } from "@/lib/hooks/useLiveFeed"; -import type { TranslatedEvent } from "@/lib/translator/types"; -import type { RawEvent, CustomAbi } from "@/lib/translator/types"; import { translateEvents } from "@/lib/translator/registry"; import type { TranslatedEvent, RawEvent, CustomAbi } from "@/lib/translator/types"; -function simulateNetworkDelay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +// --------------------------------------------------------------------------- +// Types for the server response +// --------------------------------------------------------------------------- +interface PaginationMeta { + total: number; + page: number; + limit: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +interface EventsApiResponse { + events: RawEvent[]; + pagination: PaginationMeta; + meta: { network: string; fallback?: boolean }; } +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export function DashboardClient(): React.JSX.Element { - const [rawEvents, setRawEvents] = useState(MOCK_RAW_EVENTS); + const [serverEvents, setServerEvents] = useState([]); const [liveEvents, setLiveEvents] = useState([]); const [customAbis, setCustomAbis] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isUploadOpen, setIsUploadOpen] = useState(false); const [isExportOpen, setIsExportOpen] = useState(false); - const [liveEvents, setLiveEvents] = useState([]); + const [pagination, setPagination] = useState({ + total: 0, + page: 1, + limit: 20, + totalPages: 1, + hasNext: false, + hasPrev: false, + }); const { language } = useLanguage(); const { network } = useNetwork(); const { prefs, ready, update, toggleColumn, toggleFavorite } = useDashboardPrefs(); - const { filters, setFilters } = useEventFilters(); + const { filters, setFilters, setPage, clearAll } = useEventFilters(); + // Load custom ABIs from localStorage on mount useEffect(function () { setCustomAbis(loadCustomAbis()); }, []); @@ -70,71 +91,104 @@ export function DashboardClient(): React.JSX.Element { [customAbis] ); - // Derive translations from the raw events + current custom blueprints so the - // feed re-translates instantly when an ABI is uploaded or removed. - const translatedRawEvents = useMemo( + // ─── Server-side fetch: triggered on filter / page / network changes ────── + useEffect( function () { - return translateEvents(rawEvents, customBlueprints); - }, - [rawEvents, customBlueprints] - ); + const controller = new AbortController(); + + async function fetchEvents(): Promise { + setIsLoading(true); + setError(null); + + const params = new URLSearchParams(); + if (filters.contractId) params.set("contractId", filters.contractId); + if (filters.eventType) params.set("eventType", filters.eventType); + if (filters.network) { + params.set("network", filters.network); + } else if (network) { + params.set("network", network); + } + params.set("page", String(filters.page)); + params.set("limit", "20"); - // Merge live-streamed events (prepended) with the translated batch. - const events = useMemo( - function () { - return [...liveEvents, ...translatedRawEvents]; + try { + const res = await fetch(`/api/v1/events?${params.toString()}`, { + signal: controller.signal, + }); + + if (!res.ok) { + throw new Error(`Server responded with ${res.status}`); + } + + const data: EventsApiResponse = await res.json(); + setServerEvents(data.events); + setPagination(data.pagination); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") return; + + console.error("Failed to fetch events from server, using mock data:", err); + + // Fallback to client-side mock data + const fallback = filters.contractId + ? MOCK_RAW_EVENTS.filter((e) => e.contractId === filters.contractId) + : MOCK_RAW_EVENTS; + + setServerEvents(fallback); + setPagination({ + total: fallback.length, + page: 1, + limit: 20, + totalPages: Math.max(1, Math.ceil(fallback.length / 20)), + hasNext: false, + hasPrev: false, + }); + } finally { + setIsLoading(false); + } + } + + fetchEvents(); + return () => controller.abort(); }, - [liveEvents, translatedRawEvents] + [filters.contractId, filters.eventType, filters.network, filters.page, network] ); - const handleNewEvent = useCallback((event: TranslatedEvent) => { - setLiveEvents((prev) => [event, ...prev]); - }, []); + // Translate server events with custom blueprints const translatedEvents = useMemo( - () => translateEvents(rawEvents, customBlueprints, language), - [rawEvents, customBlueprints, language] + () => translateEvents(serverEvents, customBlueprints, language), + [serverEvents, customBlueprints, language] ); + // Merge live-streamed events (prepended) with the server-fetched batch const allEvents = useMemo( () => [...liveEvents, ...translatedEvents], [liveEvents, translatedEvents] ); + // Client-side secondary filtering for ledger range and amount const filteredEvents = useMemo( () => allEvents.filter((event) => { - if (filters.contractId && event.raw.contractId !== filters.contractId) { - return false; - } - - if (filters.eventType) { - const normalizedEventType = filters.eventType.toLowerCase(); - const translatedType = event.eventType?.toLowerCase() ?? ""; - if (!translatedType.includes(normalizedEventType)) { - return false; - } - } - if (filters.minAmount !== undefined) { - const amount = Number(event.raw.data ? BigInt("0x" + event.raw.data.slice(2).replace(/[^0-9a-fA-F]/g, "0")) : 0n); - if (Number(amount) < filters.minAmount) { - return false; - } + const amount = Number( + event.raw.data + ? BigInt("0x" + event.raw.data.slice(2).replace(/[^0-9a-fA-F]/g, "0")) + : 0n + ); + if (amount < filters.minAmount) return false; } - if (filters.startLedger !== undefined && event.raw.ledger < filters.startLedger) { return false; } - if (filters.endLedger !== undefined && event.raw.ledger > filters.endLedger) { return false; } - return true; }), [allEvents, filters] ); + // ─── Live feed handler ──────────────────────────────────────────────────── const handleNewEvent = useCallback( function (event: TranslatedEvent): void { if (filters.contractId && event.raw.contractId !== filters.contractId) return; @@ -146,6 +200,7 @@ export function DashboardClient(): React.JSX.Element { const { isLive, isPaused, newEventIds, toggleLive, togglePause } = useLiveFeed(handleNewEvent); + // ─── ABI handlers ───────────────────────────────────────────────────────── const handleAbiUpload = useCallback(function (abi: CustomAbi): void { setCustomAbis(saveCustomAbi(abi)); setIsUploadOpen(false); @@ -166,6 +221,11 @@ export function DashboardClient(): React.JSX.Element { ? prefs.favorites.includes(filters.contractId) : false; + // ─── Pagination handlers ────────────────────────────────────────────────── + function goToPage(page: number): void { + setPage(Math.max(1, Math.min(page, pagination.totalPages))); + } + return (
{/* Pinned contracts sidebar */} @@ -229,27 +289,6 @@ export function DashboardClient(): React.JSX.Element {
)} - {/* Active filter indicator */} - {searchedContract && ( -
- Showing events for: - - {searchedContract.slice(0, 10)}...{searchedContract.slice(-6)} - - -
- )} - {/* Custom ABIs */}
{filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""} + {pagination.total > 0 && ` of ${pagination.total}`} @@ -352,6 +392,76 @@ export function DashboardClient(): React.JSX.Element { )}
+ {/* ── Pagination controls ─────────────────────────────────────────────── */} + {pagination.totalPages > 1 && ( + + )} + {/* Contribute banner */}
({ value: et, label: et })), + ]; }, [eventTypeSuggestions] ); - const uniqueContractSuggestions = useMemo( + const contractOptions: SelectOption[] = useMemo( function () { - return Array.from( - new Set([ - ...EXAMPLE_CONTRACTS.map((contract) => contract.id), - ...contractSuggestions, - ]) - ); + const seen = new Set(); + const opts: SelectOption[] = [{ value: "", label: "All Contracts" }]; + + for (const ex of EXAMPLE_CONTRACTS) { + if (!seen.has(ex.id)) { + seen.add(ex.id); + opts.push({ value: ex.id, label: `${ex.label} (${ex.id.slice(0, 6)}…${ex.id.slice(-4)})` }); + } + } + + for (const id of contractSuggestions) { + if (!seen.has(id)) { + seen.add(id); + opts.push({ value: id, label: `${id.slice(0, 6)}…${id.slice(-4)}` }); + } + } + + return opts; }, [contractSuggestions] ); @@ -105,12 +120,14 @@ export function FilterBuilder({ const hasAnyFilter = Boolean(filters.contractId) || Boolean(filters.eventType) || + Boolean(filters.network) || filters.minAmount !== undefined || filters.startLedger !== undefined || filters.endLedger !== undefined; function setParam(key: keyof typeof rawParams, value: string | null): void { - setFilters({ [key]: value }); + // Reset page to 1 whenever a filter changes + setFilters({ [key]: value, page: "1" }); } function handleContractSubmit(): void { @@ -118,11 +135,6 @@ export function FilterBuilder({ setParam("contractId", value || null); } - function handleEventTypeSubmit(): void { - const value = eventTypeInput.trim(); - setParam("eventType", value || null); - } - function handleNumericSubmit(key: keyof typeof rawParams, value: string): void { const trimmed = value.trim(); setParam(key, trimmed || null); @@ -131,57 +143,69 @@ export function FilterBuilder({ return (
+ {/* ── Network Select ─────────────────────────────────── */} +
+ )} + {/* ── Event Topic Select ──────────────────────────────── */}
+ {/* ── Active filter badges ─────────────────────────────── */}
+ {filters.network && ( + + Network: {filters.network} + + + )} {filters.contractId && ( - Contract: {filters.contractId} + Contract: {filters.contractId.slice(0, 6)}…{filters.contractId.slice(-4)}
+ ); +} diff --git a/lib/hooks/useEventFilters.ts b/lib/hooks/useEventFilters.ts index 9c1a1d5..b40ea33 100644 --- a/lib/hooks/useEventFilters.ts +++ b/lib/hooks/useEventFilters.ts @@ -6,17 +6,21 @@ import { useUrlSync } from "@/lib/hooks/useUrlSync"; export interface EventFilters { contractId?: string; eventType?: string; + network?: string; minAmount?: number; startLedger?: number; endLedger?: number; + page: number; } export interface EventFilterParams { contractId?: string | null; eventType?: string | null; + network?: string | null; minAmount?: string | null; startLedger?: string | null; endLedger?: string | null; + page?: string | null; } export function parseNumber(value: string | null): number | undefined { @@ -26,12 +30,15 @@ export function parseNumber(value: string | null): number | undefined { } export function parseEventFilterParams(raw: EventFilterParams): EventFilters { + const page = parseNumber(raw.page ?? null); return { contractId: raw.contractId || undefined, eventType: raw.eventType || undefined, + network: raw.network || undefined, minAmount: parseNumber(raw.minAmount ?? null), startLedger: parseNumber(raw.startLedger ?? null), endLedger: parseNumber(raw.endLedger ?? null), + page: page && page >= 1 ? page : 1, }; } @@ -40,21 +47,25 @@ export function useEventFilters() { const contractId = urlSync.get("contractId"); const eventType = urlSync.get("eventType"); + const network = urlSync.get("network"); const minAmountRaw = urlSync.get("minAmount"); const startLedgerRaw = urlSync.get("startLedger"); const endLedgerRaw = urlSync.get("endLedger"); + const pageRaw = urlSync.get("page"); const filters = useMemo( function () { return parseEventFilterParams({ contractId, eventType, + network, minAmount: minAmountRaw, startLedger: startLedgerRaw, endLedger: endLedgerRaw, + page: pageRaw, }); }, - [contractId, eventType, minAmountRaw, startLedgerRaw, endLedgerRaw] + [contractId, eventType, network, minAmountRaw, startLedgerRaw, endLedgerRaw, pageRaw] ); const setFilters = useCallback( @@ -64,14 +75,23 @@ export function useEventFilters() { [urlSync] ); + const setPage = useCallback( + function (page: number): void { + urlSync.setParams({ page: String(page) }); + }, + [urlSync] + ); + const clearAll = useCallback( function (): void { urlSync.setParams({ contractId: null, eventType: null, + network: null, minAmount: null, startLedger: null, endLedger: null, + page: null, }); }, [urlSync] @@ -82,11 +102,14 @@ export function useEventFilters() { rawParams: { contractId, eventType, + network, minAmount: minAmountRaw, startLedger: startLedgerRaw, endLedger: endLedgerRaw, + page: pageRaw, }, setFilters, + setPage, clearAll, }; }