diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx index 2014c24..35cfbb8 100644 --- a/app/dashboard/DashboardClient.tsx +++ b/app/dashboard/DashboardClient.tsx @@ -1,20 +1,19 @@ "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 * as React from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AlertCircle, - BookOpen, ArrowRight, - Radio, + BookOpen, + Download, + FileJson, PauseCircle, PlayCircle, - Upload, - FileJson, - Trash2, - Download, + Radio, Star, + Trash2, + Upload, } from "lucide-react"; import { FilterBuilder } from "@/components/dashboard/FilterBuilder"; import { EventFeedTable } from "@/components/dashboard/EventFeedTable"; @@ -27,124 +26,178 @@ import { useLiveFeed } from "@/lib/hooks/useLiveFeed"; 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 { useEventFilters, type EventFilters } from "@/lib/hooks/useEventFilters"; +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"; +import type { EmptyStateCause } from "@/components/dashboard/EmptyState"; +import type { CustomAbi, RawEvent, TranslatedEvent } from "@/lib/translator/types"; + +const INITIAL_EVENT_LOAD_MS = 250; function simulateNetworkDelay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise(function (resolve) { + window.setTimeout(resolve, ms); + }); +} + +function hasActiveFilters(filters: EventFilters): boolean { + return ( + Boolean(filters.contractId) || + Boolean(filters.eventType) || + filters.minAmount !== undefined || + filters.startLedger !== undefined || + filters.endLedger !== undefined + ); +} + +function readEventAmount(data: string): number { + const normalized = data.slice(2).replace(/[^0-9a-fA-F]/g, ""); + if (!normalized) return 0; + return Number(BigInt(`0x${normalized}`)); +} + +function eventMatchesFilters(event: TranslatedEvent, filters: EventFilters): boolean { + 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 && readEventAmount(event.raw.data) < 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; } export function DashboardClient(): React.JSX.Element { - const [rawEvents, setRawEvents] = useState(MOCK_RAW_EVENTS); + const [rawEvents, setRawEvents] = useState([]); const [liveEvents, setLiveEvents] = useState([]); const [customAbis, setCustomAbis] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isUploadOpen, setIsUploadOpen] = useState(false); const [isExportOpen, setIsExportOpen] = useState(false); - const [liveEvents, setLiveEvents] = useState([]); const { language } = useLanguage(); const { network } = useNetwork(); const { prefs, ready, update, toggleColumn, toggleFavorite } = useDashboardPrefs(); - const { filters, setFilters } = useEventFilters(); + const { filters, setFilters, clearAll } = useEventFilters(); useEffect(function () { setCustomAbis(loadCustomAbis()); }, []); - const customBlueprints = useMemo( - () => buildCustomBlueprints(customAbis), - [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( + useEffect( function () { - return translateEvents(rawEvents, customBlueprints); + let isCurrent = true; + + setIsLoading(true); + setError(null); + + simulateNetworkDelay(INITIAL_EVENT_LOAD_MS) + .then(function () { + if (!isCurrent) return; + setRawEvents(MOCK_RAW_EVENTS); + }) + .catch(function (reason: unknown) { + if (!isCurrent) return; + setRawEvents([]); + setError( + reason instanceof Error + ? reason.message + : "Could not load event history." + ); + }) + .finally(function () { + if (isCurrent) setIsLoading(false); + }); + + return function cleanup(): void { + isCurrent = false; + }; }, - [rawEvents, customBlueprints] + [network] ); - // Merge live-streamed events (prepended) with the translated batch. - const events = useMemo( + const customBlueprints = useMemo( function () { - return [...liveEvents, ...translatedRawEvents]; + return buildCustomBlueprints(customAbis); }, - [liveEvents, translatedRawEvents] + [customAbis] ); - const handleNewEvent = useCallback((event: TranslatedEvent) => { - setLiveEvents((prev) => [event, ...prev]); - }, []); const translatedEvents = useMemo( - () => translateEvents(rawEvents, customBlueprints, language), + function () { + return translateEvents(rawEvents, customBlueprints, language); + }, [rawEvents, customBlueprints, language] ); + const handleNewEvent = useCallback( + function (event: TranslatedEvent): void { + if (filters.contractId && event.raw.contractId !== filters.contractId) return; + setLiveEvents(function (prev) { + return [event, ...prev]; + }); + }, + [filters.contractId] + ); + + const { + isLive, + isPaused, + connectionStatus, + newEventIds, + toggleLive, + togglePause, + } = useLiveFeed(handleNewEvent); + const allEvents = useMemo( - () => [...liveEvents, ...translatedEvents], + function () { + return [...liveEvents, ...translatedEvents]; + }, [liveEvents, translatedEvents] ); 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; - } - } - - 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] - ); - - const handleNewEvent = useCallback( - function (event: TranslatedEvent): void { - if (filters.contractId && event.raw.contractId !== filters.contractId) return; - setLiveEvents((prev) => [event, ...prev]); + function () { + return allEvents.filter(function (event) { + return eventMatchesFilters(event, filters); + }); }, - [filters.contractId] + [allEvents, filters] ); - const { isLive, isPaused, newEventIds, toggleLive, togglePause } = - useLiveFeed(handleNewEvent); + const activeFilters = hasActiveFilters(filters); + const emptyStateCause: EmptyStateCause = + connectionStatus === "error" + ? "connection-error" + : activeFilters + ? "filtered" + : "waiting"; + const isEventFeedLoading = + isLoading || (connectionStatus === "connecting" && allEvents.length === 0); const handleAbiUpload = useCallback(function (abi: CustomAbi): void { setCustomAbis(saveCustomAbi(abi)); @@ -166,31 +219,43 @@ export function DashboardClient(): React.JSX.Element { ? prefs.favorites.includes(filters.contractId) : false; + function handleToggleFilteredFavorite(): void { + if (filters.contractId) { + toggleFavorite(filters.contractId); + } + } + return (
- {/* Pinned contracts sidebar */} {ready && ( )} - {/* Search + favorite toggle */}
event.eventType) - .filter((value): value is string => Boolean(value)) + .map(function (event) { + return event.eventType; + }) + .filter(function (value): value is string { + return Boolean(value); + }) ) )} contractSuggestions={Array.from( - new Set(allEvents.map((event) => event.raw.contractId)) + new Set( + allEvents.map(function (event) { + return event.raw.contractId; + }) + ) )} /> @@ -200,7 +265,7 @@ export function DashboardClient(): React.JSX.Element { variant="ghost" size="icon" className="mt-0.5 h-9 w-9 shrink-0" - onClick={() => toggleFavorite(filters.contractId)} + onClick={handleToggleFilteredFavorite} aria-label={isFavorited ? "Unpin this contract" : "Pin this contract"} title={isFavorited ? "Unpin contract" : "Pin contract"} > @@ -212,13 +277,14 @@ export function DashboardClient(): React.JSX.Element { }`} /> - Filtered contract is pinned / unpinned by toggle. + + Filtered contract is pinned / unpinned by toggle. +
)}
- {/* Error state */} {error && (
)} - {/* Active filter indicator */} - {searchedContract && ( -
- Showing events for: - - {searchedContract.slice(0, 10)}...{searchedContract.slice(-6)} - - -
- )} - - {/* Custom ABIs */}
- {customAbis.map((abi) => ( - - - {abi.contractName} - - - ))} + + {abi.contractName} + + + ); + })}
- {/* Stats */} - {!isLoading && } + {!isEventFeedLoading && } - {/* Event feed */}
-
+

Event Feed

-
+
- {ready && ( - update({ density: d })} - /> - )} +
- {/* Contribute banner */}
-
-
-
{children}
-
diff --git a/components/dashboard/EmptyState.test.tsx b/components/dashboard/EmptyState.test.tsx new file mode 100644 index 0000000..72af47a --- /dev/null +++ b/components/dashboard/EmptyState.test.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EmptyState } from "./EmptyState"; + +describe("EmptyState", () => { + it("renders the waiting message", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("No events found"); + expect(screen.getByText("Waiting for events on the Stellar network...")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear search" })).not.toBeInTheDocument(); + }); + + it("renders the filtered message and clear action", () => { + const onClearSearch = vi.fn(); + + render(); + + expect(screen.getByText("No events match your search.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(onClearSearch).toHaveBeenCalledTimes(1); + }); + + it("renders the connection error message", () => { + render(); + + expect(screen.getByText("Could not connect to Stellar. Retrying...")).toBeInTheDocument(); + }); +}); diff --git a/components/dashboard/EmptyState.tsx b/components/dashboard/EmptyState.tsx new file mode 100644 index 0000000..4c64945 --- /dev/null +++ b/components/dashboard/EmptyState.tsx @@ -0,0 +1,75 @@ +"use client"; + +import * as React from "react"; +import { Radio, Search, WifiOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type EmptyStateCause = "waiting" | "filtered" | "connection-error"; + +interface EmptyStateProps { + cause: EmptyStateCause; + onClearSearch?: () => void; + className?: string; +} + +const COPY: Record< + EmptyStateCause, + { + title: string; + description: string; + icon: typeof Search; + } +> = { + waiting: { + title: "No events found", + description: "Waiting for events on the Stellar network...", + icon: Radio, + }, + filtered: { + title: "No events found", + description: "No events match your search.", + icon: Search, + }, + "connection-error": { + title: "No events found", + description: "Could not connect to Stellar. Retrying...", + icon: WifiOff, + }, +}; + +export function EmptyState({ + cause, + onClearSearch, + className, +}: EmptyStateProps): React.JSX.Element { + const { title, description, icon: Icon } = COPY[cause]; + + return ( +
+
+
+

{title}

+

{description}

+ {cause === "filtered" && onClearSearch && ( + + )} +
+ ); +} diff --git a/components/dashboard/EventDetailsModal.tsx b/components/dashboard/EventDetailsModal.tsx index 5e80e11..73b9cfe 100644 --- a/components/dashboard/EventDetailsModal.tsx +++ b/components/dashboard/EventDetailsModal.tsx @@ -1,5 +1,6 @@ "use client" +import * as React from "react" import { Code, ExternalLink } from "lucide-react" import { Dialog, diff --git a/components/dashboard/EventFeedTable.test.tsx b/components/dashboard/EventFeedTable.test.tsx index 240dd14..df92860 100644 --- a/components/dashboard/EventFeedTable.test.tsx +++ b/components/dashboard/EventFeedTable.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { axe } from "vitest-axe"; import { EventFeedTable } from "./EventFeedTable"; @@ -15,6 +15,8 @@ describe("EventFeedTable Accessibility", () => { status: "translated" as const, description: "Transferred 100 XLM to Bob", eventType: "transfer", + blueprintName: "Mock Blueprint", + schemaVersion: "test", raw: { id: "1", type: "contract", @@ -32,6 +34,8 @@ describe("EventFeedTable Accessibility", () => { status: "cryptic" as const, description: "", eventType: "", + blueprintName: null, + schemaVersion: null, raw: { id: "2", type: "contract", @@ -66,6 +70,79 @@ describe("EventFeedTable Accessibility", () => { ); const results = await axe(container); - expect(results).toHaveNoViolations(); + expect(results.violations).toHaveLength(0); + }); + + it("renders the skeleton while loading", () => { + const columns = { + status: true, + time: true, + description: true, + contract: true, + actions: true, + }; + + render( + + ); + + expect(screen.getByLabelText("Loading events")).toHaveAttribute("aria-busy", "true"); + }); + + it("renders filtered empty state and clear action", () => { + const columns = { + status: true, + time: true, + description: true, + contract: true, + actions: true, + }; + const onClearSearch = vi.fn(); + + render( + + ); + + expect(screen.getByText("No events match your search.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(onClearSearch).toHaveBeenCalledTimes(1); + }); + + it("renders connection error empty state", () => { + const columns = { + status: true, + time: true, + description: true, + contract: true, + actions: true, + }; + + render( + + ); + + expect(screen.getByText("Could not connect to Stellar. Retrying...")).toBeInTheDocument(); }); }); diff --git a/components/dashboard/EventFeedTable.tsx b/components/dashboard/EventFeedTable.tsx index 33669b1..dd9eb34 100644 --- a/components/dashboard/EventFeedTable.tsx +++ b/components/dashboard/EventFeedTable.tsx @@ -1,5 +1,6 @@ "use client"; +import * as React from "react"; import { useState } from "react"; import { CheckCircle2, HelpCircle, Clock, Eye, GitBranch, Settings2, Network } from "lucide-react"; import { @@ -20,6 +21,8 @@ import { } from "@/components/ui/dialog"; import { EventDetailsModal } from "./EventDetailsModal"; import { ContributeDialog } from "./ContributeDialog"; +import { EmptyState, type EmptyStateCause } from "./EmptyState"; +import { EventListSkeleton } from "./EventListSkeleton"; import { DagPanel } from "@/components/dag/DagPanel"; import { formatRelativeTime, truncateHex } from "@/lib/translator/decode"; import type { TranslatedEvent, RawEvent } from "@/lib/translator/types"; @@ -37,10 +40,12 @@ interface EventFeedTableProps { events: TranslatedEvent[]; isLoading?: boolean; newEventIds?: Set; + emptyStateCause?: EmptyStateCause; columns: ColumnVisibility; density: Density; onToggleColumn: (col: keyof ColumnVisibility) => void; onDensityChange: (d: Density) => void; + onClearSearch?: () => void; } function StatusBadge({ status }: { status: TranslatedEvent["status"] }): React.JSX.Element { @@ -73,35 +78,23 @@ function StatusBadge({ status }: { status: TranslatedEvent["status"] }): React.J ); } -function SkeletonRow({ colCount }: { colCount: number }): React.JSX.Element { - return ( - - {Array.from({ length: colCount }).map(function (_, i) { - return ( - -
- - ); - })} - - ); -} - export function EventFeedTable({ events, isLoading = false, newEventIds = new Set(), + emptyStateCause = "waiting", columns, density, onToggleColumn, onDensityChange, + onClearSearch, }: EventFeedTableProps): React.JSX.Element { const [detailsEvent, setDetailsEvent] = useState(null); const [contributeDialogEvent, setContributeDialogEvent] = useState(null); const [showColMenu, setShowColMenu] = useState(false); const [dagTxHash, setDagTxHash] = useState(null); - const handleKeyDown = (e: React.KeyboardEvent) => { + function handleKeyDown(e: React.KeyboardEvent): void { if (e.target instanceof HTMLElement && e.target.tagName === "TR") { const currentRow = e.target as HTMLTableRowElement; @@ -115,10 +108,10 @@ export function EventFeedTable({ if (prevRow) prevRow.focus(); } } - }; + } const cellPadding = density === "compact" ? "py-1.5" : "py-3"; - const visibleColCount = Object.values(columns).filter(Boolean).length; + const visibleColCount = Math.max(Object.values(columns).filter(Boolean).length, 1); return ( <> @@ -192,12 +185,11 @@ export function EventFeedTable({ )} - - {isLoading - ? Array.from({ length: 5 }).map(function (_, i) { - return ; - }) - : events.map(function (event) { + {isLoading ? ( + + ) : ( + + {events.map(function (event) { const isTranslated = event.status === "translated"; return ( @@ -301,17 +293,18 @@ export function EventFeedTable({ ); })} - {!isLoading && events.length === 0 && ( - - - No events found. Enter a Contract ID above to search. - - - )} - + {events.length === 0 && ( + + + + + + )} + + )}
diff --git a/components/dashboard/EventListSkeleton.test.tsx b/components/dashboard/EventListSkeleton.test.tsx new file mode 100644 index 0000000..68e22c6 --- /dev/null +++ b/components/dashboard/EventListSkeleton.test.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + Table, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { EventListSkeleton } from "./EventListSkeleton"; +import type { ColumnVisibility } from "@/lib/hooks/useDashboardPrefs"; + +const columns: ColumnVisibility = { + status: true, + time: true, + description: true, + contract: true, + actions: true, +}; + +describe("EventListSkeleton", () => { + it("renders an accessible busy table body with placeholder rows", () => { + render( + + + + Status + Time + Description + Contract + Actions + + + +
+ ); + + expect(screen.getByLabelText("Loading events")).toHaveAttribute("aria-busy", "true"); + expect(screen.getAllByRole("row", { hidden: true })).toHaveLength(8); + }); +}); diff --git a/components/dashboard/EventListSkeleton.tsx b/components/dashboard/EventListSkeleton.tsx new file mode 100644 index 0000000..bed9346 --- /dev/null +++ b/components/dashboard/EventListSkeleton.tsx @@ -0,0 +1,94 @@ +"use client"; + +import * as React from "react"; +import { + TableBody, + TableCell, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import type { ColumnVisibility, Density } from "@/lib/hooks/useDashboardPrefs"; + +interface EventListSkeletonProps { + columns: ColumnVisibility; + density: Density; + rowCount?: number; +} + +function SkeletonBlock({ className }: { className: string }): React.JSX.Element { + return ( +
+ ); +} + +function SkeletonRow({ + columns, + density, +}: { + columns: ColumnVisibility; + density: Density; +}): React.JSX.Element { + const cellPadding = density === "compact" ? "py-2" : "py-3"; + + return ( + + ); +} + +export function EventListSkeleton({ + columns, + density, + rowCount = 6, +}: EventListSkeletonProps): React.JSX.Element { + return ( + + + Loading events + + {Array.from({ length: rowCount }).map(function (_, index) { + return ; + })} + + ); +} diff --git a/lib/hooks/useDashboardPrefs.ts b/lib/hooks/useDashboardPrefs.ts index df85bf8..6829288 100644 --- a/lib/hooks/useDashboardPrefs.ts +++ b/lib/hooks/useDashboardPrefs.ts @@ -1,7 +1,6 @@ "use client"; -import { useCallback } from "react"; -import { useLocalStorage } from "./useLocalStorage"; +import { useCallback, useEffect, useState } from "react"; export type Density = "compact" | "comfortable"; diff --git a/lib/hooks/useLiveFeed.ts b/lib/hooks/useLiveFeed.ts index f390500..a20eb47 100644 --- a/lib/hooks/useLiveFeed.ts +++ b/lib/hooks/useLiveFeed.ts @@ -1,9 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { TranslatedEvent } from "../translator/types"; +export type LiveFeedConnectionStatus = "idle" | "connecting" | "open" | "error"; + export interface LiveFeedState { isLive: boolean; isPaused: boolean; + connectionStatus: LiveFeedConnectionStatus; + errorMessage: string | null; newEventIds: Set; toggleLive: () => void; togglePause: () => void; @@ -52,6 +56,9 @@ function computeBackoffDelay(attempt: number): number { export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeedState { const [isLive, setIsLive] = useState(false); const [isPaused, setIsPaused] = useState(false); + const [connectionStatus, setConnectionStatus] = + useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); const [newEventIds, setNewEventIds] = useState>(new Set()); const wsRef = useRef(null); @@ -111,12 +118,22 @@ export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeed // Guard: do not open a second socket if one is already live. if (wsRef.current !== null) return; + setConnectionStatus("connecting"); + setErrorMessage(null); + const ws = new WebSocket(WS_URL); wsRef.current = ws; ws.onopen = () => { // Successful handshake — reset the backoff counter. attemptRef.current = 0; + setConnectionStatus("open"); + setErrorMessage(null); + }; + + ws.onerror = () => { + setConnectionStatus("error"); + setErrorMessage("Could not connect to Stellar. Retrying..."); }; ws.onmessage = (e: MessageEvent) => { @@ -131,7 +148,7 @@ export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeed setNewEventIds((prev) => new Set(prev).add(event.raw.id)); // Remove the highlight badge after the animation completes (600 ms). - setTimeout(() => { + const timeoutId = setTimeout(() => { setNewEventIds((prev) => { const next = new Set(prev); next.delete(event.raw.id); @@ -148,6 +165,9 @@ export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeed // Only schedule a reconnect if the feed is still supposed to be live. if (!isLiveRef.current) return; + setConnectionStatus("error"); + setErrorMessage("Could not connect to Stellar. Retrying..."); + const delay = computeBackoffDelay(attemptRef.current); attemptRef.current += 1; @@ -169,6 +189,8 @@ export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeed const disconnect = useCallback(() => { closeSocket(); attemptRef.current = 0; + setConnectionStatus("idle"); + setErrorMessage(null); }, [closeSocket]); const toggleLive = useCallback(() => { @@ -226,5 +248,13 @@ export function useLiveFeed(onEvent: (event: TranslatedEvent) => void): LiveFeed }; }, [disconnect]); - return { isLive, isPaused, newEventIds, toggleLive, togglePause }; + return { + isLive, + isPaused, + connectionStatus, + errorMessage, + newEventIds, + toggleLive, + togglePause, + }; } diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index f244044..7c9a9ac 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -23,8 +23,6 @@ import { decodeEventName } from "./core"; import { sanitizeTextField } from "./core"; import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder"; import { RegistryTemplateException } from "../errors"; -import { captureExceptionSync } from "../telemetry"; -import { getCachedTranslation, setCachedTranslation, isRedisEnabled } from "../cache/redisCache"; import type { EventMatchCriteria, RawEvent, @@ -37,9 +35,6 @@ import type { TranslationResult, } from "./types"; -/** The registry maps contract IDs to their versioned entries. */ -type BlueprintRegistry = Map; - /** Cache for resolved schemas to avoid repeated scans of the registry. */ const RESOLUTION_CACHE: Map = new Map(); @@ -47,10 +42,19 @@ const RESOLUTION_CACHE: Map = new Map(); * Interpolates a template string with values from an object. * e.g. "Hello {name}" + { name: "World" } -> "Hello World" */ -type BlueprintRegistry = Map; +/** The registry maps contract IDs to their versioned entries. */ +type BlueprintRegistry = Map; export type PersistedRawEvent = RawEvent & Partial>; +interface EventMappingDefinition { + eventType?: string; + description?: string; + template?: string; + english_template?: string; + topics?: EventMatchCriteria["topics"] | string[]; +} + function hasPersistedTranslation(event: PersistedRawEvent): boolean { return ( event.status !== undefined || @@ -77,8 +81,15 @@ export async function translateWithCache( customBlueprints?: Map, lang: Language = "en" ): Promise { - if (event.txHash && event.id && isRedisEnabled()) { - const cached = await getCachedTranslation(event); + const cache = + typeof window === "undefined" + ? await import("../cache/redisCache").catch(function () { + return null; + }) + : null; + + if (event.txHash && event.id && cache?.isRedisEnabled()) { + const cached = await cache.getCachedTranslation(event); if (cached) return cached; } @@ -87,8 +98,8 @@ export async function translateWithCache( ? buildTranslationFromPersisted(event) : translateEvent(event, customBlueprints, lang); - if (event.txHash && event.id && isRedisEnabled()) { - await setCachedTranslation(event, translated); + if (event.txHash && event.id && cache?.isRedisEnabled()) { + await cache.setCachedTranslation(event, translated); } return translated; @@ -141,11 +152,21 @@ function buildRegistry(): BlueprintRegistry { const mintBurnBlueprint = createSacMintBurnBlueprint(contractId); const existing = registry.get(contractId); if (existing) { - const existingBlueprint = Array.isArray(existing) ? existing[0] : existing; - const originalTranslate = existingBlueprint.translate.bind(existingBlueprint); - registry.set(contractId, { - ...mintBurnBlueprint, - translate: (event, lang) => originalTranslate(event, lang) ?? mintBurnBlueprint.translate(event, lang), + const existingSchema = existing.schemas[0]; + if (!existingSchema) { + register(mintBurnBlueprint); + continue; + } + + const existingBlueprint = existingSchema.blueprint; + existingSchema.blueprint = { + ...existingBlueprint, + translate: function (event, lang) { + return ( + existingBlueprint.translate(event, lang) ?? + mintBurnBlueprint.translate(event, lang) + ); + }, }; } else { register(mintBurnBlueprint); @@ -163,7 +184,7 @@ export function registerUpgrade( contractId: string, version: string, fromLedger: number, - eventMappings: any[] + eventMappings: EventMappingDefinition[] ) { const entry = REGISTRY.get(contractId); if (!entry) return; @@ -200,6 +221,50 @@ export function registerUpgrade( }); } +function createTranslateFromMapping( + mapping: EventMappingDefinition +): TranslationBlueprint["translate"] { + return function (event: RawEvent): TranslationResult | null { + if (mapping.topics && !mappingMatchesTopics(event, mapping.topics)) { + return null; + } + + const eventType = sanitizeTextField(mapping.eventType ?? "Event", { + maxLength: 64, + }); + const description = sanitizeTextField( + mapping.description ?? + mapping.template ?? + mapping.english_template ?? + `${eventType} event emitted.`, + { maxLength: 512 } + ); + + return { + description, + eventType, + }; + }; +} + +function mappingMatchesTopics( + event: RawEvent, + topics: EventMappingDefinition["topics"] +): boolean { + if (!topics) return true; + if (topics.length === 0) return true; + + if (typeof topics[0] === "string") { + return (topics as string[]).every(function (topic, index) { + return event.topics[index] === topic; + }); + } + + return matchesEventCriteria(event, { + topics: topics as EventMatchCriteria["topics"], + }); +} + /** Singleton registry instance. */ const REGISTRY: BlueprintRegistry = buildRegistry(); @@ -256,8 +321,9 @@ export function translateEvent( ): TranslatedEvent { const schema = resolveSchema(event.contractId, event.ledger, customBlueprints); - if (!entry) { + if (!schema) { console.warn(`No translation blueprint found for contract ${event.contractId}`); + const custom = customBlueprints?.get(event.contractId); // Try to decode the event using the generic fallback decoder const genericDecoded = decodeGenericEventPayload(event); @@ -276,23 +342,7 @@ export function translateEvent( }; } - const blueprint = Array.isArray(entry) - ? resolveBlueprint(entry, event.ledger) - : entry; - - if (!blueprint) { - console.warn(`No translation blueprint applicable for contract ${event.contractId} at ledger ${event.ledger}`); - return { - raw: event, - description: `[Unknown Event: No blueprint applicable for contract ${event.contractId} at ledger ${event.ledger}. Hex Data: ${event.data}]`, - status: "cryptic", - blueprintName: Array.isArray(entry) ? entry[0].contractName : entry.contractName, - eventType: null, - schemaVersion: null, - }; - } - - const translated = applyBlueprint(event, blueprint, lang); + const translated = applyBlueprint(event, schema.blueprint, lang); if (translated) return translated; return { @@ -301,7 +351,7 @@ export function translateEvent( status: "cryptic", blueprintName: schema.blueprint.contractName, eventType: null, - schemaVersion: null, + schemaVersion: schema.version, }; } @@ -315,13 +365,15 @@ function applyBlueprint(event: RawEvent, blueprint: TranslationBlueprint, lang: const result = blueprint.translate(event, lang); if (!result) return null; + const versioned = blueprint as VersionedTranslationBlueprint; + return { raw: event, description: result.description ? sanitizeTextField(result.description) : null, status: "translated", blueprintName: blueprint.contractName, eventType: result.eventType ? sanitizeTextField(result.eventType, { maxLength: 64 }) : null, - schemaVersion: (blueprint as any).version ?? null, + schemaVersion: versioned.version ?? null, }; } @@ -415,7 +467,7 @@ function translateEventSafe( }, error ); - captureExceptionSync(templateError); + console.error("[open-audit:registry]", templateError); return { raw: event, @@ -458,19 +510,33 @@ export function getBlueprintCount(): number { export function registerBlueprint(...blueprints: TranslationBlueprint[]): void { for (const blueprint of blueprints) { const existing = REGISTRY.get(blueprint.contractId); + const versioned = blueprint as VersionedTranslationBlueprint; + const schema: ContractSchema = { + version: versioned.version ?? "runtime", + validFromLedger: versioned.validFromLedger ?? 0, + validToLedger: null, + blueprint, + }; + if (!existing) { - REGISTRY.set(blueprint.contractId, blueprint); + REGISTRY.set(blueprint.contractId, { + contractId: blueprint.contractId, + contractName: blueprint.contractName, + schemas: [schema], + }); continue; } - const merged: VersionedTranslationBlueprint[] = Array.isArray(existing) - ? [...existing] - : [{ ...existing } as VersionedTranslationBlueprint]; + existing.schemas.push(schema); + existing.schemas.sort((a, b) => a.validFromLedger - b.validFromLedger); + for (let i = 0; i < existing.schemas.length - 1; i++) { + existing.schemas[i].validToLedger = existing.schemas[i + 1].validFromLedger - 1; + } - merged.push(blueprint as VersionedTranslationBlueprint); - REGISTRY.set( - blueprint.contractId, - merged.sort((a, b) => (b.validFromLedger ?? 0) - (a.validFromLedger ?? 0)) - ); + RESOLUTION_CACHE.forEach(function (_, key) { + if (key.startsWith(`${blueprint.contractId}:`)) { + RESOLUTION_CACHE.delete(key); + } + }); } -} \ No newline at end of file +} diff --git a/package.json b/package.json index eb08fe9..7b61893 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@prisma/client": "^5.22.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.2.17", "@radix-ui/react-tooltip": "^1.0.7", "@sentry/node": "^10.59.0", @@ -76,7 +77,6 @@ "isarray": "^2.0.5", "lucide-react": "^0.378.0", "next": "14.2.35", - "next": "14.2.3", "node-cron": "^3.0.3", "path-to-regexp": "^8.4.2", "pino": "^8.17.2", @@ -95,16 +95,9 @@ "tailwindcss-animate": "^1.0.7", "ws": "^8.21.0" }, - "devDependencies": { - "@types/js-yaml": "^4.0", - "three": "^0.179.0", - "to-buffer": "^1.2.2", - "until-async": "^3.0.2", - "util-deprecate": "^1.0.2", - "ws": "^8.18.0" - }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", + "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/bull": "^4.10.4", "@types/ioredis": "^4.28.10", @@ -117,12 +110,11 @@ "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", - "@vitest/ui": "^4.1.9", + "@vitest/ui": "^3.2.6", "ajv": "^8.20.0", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.2.35", - "eslint-config-next": "14.2.3", "form-data": "^4.0.6", "happy-dom": "^20.10.6", "jsdom": "^27.0.1", @@ -130,18 +122,20 @@ "postcss": "^8", "prettier": "^3.2.5", "tailwindcss": "^3.3.0", + "three": "^0.179.0", + "to-buffer": "^1.2.2", "ts-node": "^10.9.2", + "tsx": "^4.7.0", "typescript": "^5", - "vitest": "^3.2.6" + "until-async": "^3.0.2", + "util-deprecate": "^1.0.2", + "vitest": "^3.2.6", + "vitest-axe": "^0.1.0" }, "overrides": { "form-data": "^4.0.6", "glob": "^10.5.0", "js-yaml": "^4.1.2", "minimatch": "^9.0.7" - "tsx": "^4.7.0", - "typescript": "^5", - "vitest": "^2.1.8", - "vitest-axe": "^0.1.0" } }