From f7fe5380abba5e6bd3e68b65b7f59de764c1a46e Mon Sep 17 00:00:00 2001 From: stanlou Date: Sun, 1 Feb 2026 18:54:29 +0300 Subject: [PATCH] enhance explorer ux --- .../explorer/src/app/blocks/[hash]/page.tsx | 6 +- packages/explorer/src/app/page.tsx | 5 +- .../components/dashboard/DashboardStats.tsx | 108 ++++++++++- .../src/components/search/GlobalSearch.tsx | 175 +++++++++++++----- .../transactions/TransactionsPageClient.tsx | 50 +++-- .../explorer/src/components/ui/DataTable.tsx | 5 + .../src/components/ui/GenericTableRow.tsx | 20 +- packages/explorer/src/config.ts | 7 +- .../migration.sql | 10 + packages/indexer/prisma/schema.prisma | 11 ++ 10 files changed, 314 insertions(+), 83 deletions(-) create mode 100644 packages/indexer/prisma/migrations/20260201142200_transaction_priority/migration.sql diff --git a/packages/explorer/src/app/blocks/[hash]/page.tsx b/packages/explorer/src/app/blocks/[hash]/page.tsx index c90c32bd8..ed75f8824 100644 --- a/packages/explorer/src/app/blocks/[hash]/page.tsx +++ b/packages/explorer/src/app/blocks/[hash]/page.tsx @@ -104,8 +104,10 @@ export default function BlockDetail() { const transactions: TableItem[] = (data?.block?.transactions || []).map( (tx) => ({ ...tx.tx, - status: `${tx.status}`, - statusMessage: tx.statusMessage ?? "—", + status: { + isSuccess: tx.status === true, + message: tx.statusMessage, + }, }) ); diff --git a/packages/explorer/src/app/page.tsx b/packages/explorer/src/app/page.tsx index 160a48830..25719e5b0 100644 --- a/packages/explorer/src/app/page.tsx +++ b/packages/explorer/src/app/page.tsx @@ -4,6 +4,7 @@ import { Sparkles } from "lucide-react"; import GlobalSearch from "@/components/search/GlobalSearch"; import DashboardStats from "@/components/dashboard/DashboardStats"; +import config from "@/config"; export default function LandingPage() { return ( @@ -13,12 +14,12 @@ export default function LandingPage() {

- Explorer + {config.DASHBOARD_TITLE}

- Explore the blockchain. Search in real-time. + {config.DASHBOARD_SLOGAN}

diff --git a/packages/explorer/src/components/dashboard/DashboardStats.tsx b/packages/explorer/src/components/dashboard/DashboardStats.tsx index cc50a8c4a..4be988773 100644 --- a/packages/explorer/src/components/dashboard/DashboardStats.tsx +++ b/packages/explorer/src/components/dashboard/DashboardStats.tsx @@ -4,7 +4,8 @@ /* eslint-disable no-nested-ternary */ import { useCallback, useEffect, useState } from "react"; -import { Blocks, Zap, Link, Clock, Database } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Blocks, Zap, Link, Clock, Database, ArrowRight } from "lucide-react"; import { Card, @@ -13,6 +14,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import config from "@/config"; import { cn } from "@/lib/utils"; @@ -38,6 +40,11 @@ export interface GetDashboardStatsResponse { stateRoot: string; }; }>; + recentBlocks: Array<{ + height: number; + hash: string; + timestamp?: string; + }>; settlements: Array<{ transactionHash: string; promisedMessagesHash: string; @@ -57,6 +64,11 @@ interface DashboardStatsData { promisedMessagesHash: string; } | null; currentStateRoot: string; + recentBlocks: Array<{ + height: number; + hash: string; + timestamp?: string; + }>; } interface StatCardProps { @@ -111,6 +123,7 @@ function StatCard({ export default function DashboardStats() { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); + const router = useRouter(); const fetchStats = useCallback(async () => { setLoading(true); @@ -124,6 +137,10 @@ export default function DashboardStats() { fromStateRoot result { stateRoot } } + recentBlocks: blocks(take: 10, orderBy: { height: desc }) { + height + hash + } settlements(take: 1, orderBy: { transactionHash: desc }) { transactionHash promisedMessagesHash @@ -161,6 +178,7 @@ export default function DashboardStats() { data?.blocks?.[0]?.result?.stateRoot || data?.blocks?.[0]?.fromStateRoot || "—", + recentBlocks: data.recentBlocks, }); } catch (error) { console.error("Failed to fetch dashboard stats:", error); @@ -206,7 +224,10 @@ export default function DashboardStats() { loading={loading} > {stats?.latestBlock ? ( - <> +
router.push(`/blocks/${stats.latestBlock?.hash}`)} + >

Height

@@ -219,7 +240,7 @@ export default function DashboardStats() {

- + ) : (

No data available

)} @@ -230,7 +251,12 @@ export default function DashboardStats() { loading={loading} > {stats?.latestSettlement ? ( - <> +
+ router.push(`/settlements/${stats.latestSettlement?.hash}`) + } + >

Transaction Hash @@ -249,12 +275,84 @@ export default function DashboardStats() { />

- + ) : (

No data available

)} + + +
+ + + Latest Blocks + + The 10 most recently mined blocks +
+ +
+ +
+ + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + )) + ) : stats?.recentBlocks && stats.recentBlocks.length > 0 ? ( + stats.recentBlocks.map((block) => ( + router.push(`/blocks/${block.hash}`)} + > + + + + )) + ) : ( + + + + )} + +
+ Height + + Hash +
+ + + +
#{block.height} + +
+ No blocks available +
+
+
+
); } diff --git a/packages/explorer/src/components/search/GlobalSearch.tsx b/packages/explorer/src/components/search/GlobalSearch.tsx index 5a9254bf7..403a529df 100644 --- a/packages/explorer/src/components/search/GlobalSearch.tsx +++ b/packages/explorer/src/components/search/GlobalSearch.tsx @@ -1,5 +1,7 @@ "use client"; +/* eslint-disable no-underscore-dangle */ + import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Search, Loader2, Zap, Cuboid, Link } from "lucide-react"; @@ -31,76 +33,150 @@ export interface SearchResponse { }>; }; } +export interface GetBlocksMaxHeightResponse { + data: { + aggregateBlock: { + _max: { + height: number | null; + }; + }; + }; +} export default function GlobalSearch() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [showResults, setShowResults] = useState(false); + const [maxHeight, setMaxHeight] = useState(null); const router = useRouter(); - const searchIndexer = useCallback(async (searchQuery: string) => { - if (!searchQuery || searchQuery.length < 2) { - setResults([]); - return; - } + useEffect(() => { + const fetchMaxHeight = async () => { + try { + const gqlQuery = `query { + aggregateBlock { + _max { + height + } + } + }`; + const res = await fetch(`${config.INDEXER_URL}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: gqlQuery }), + }); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ + const resJson = (await res.json()) as GetBlocksMaxHeightResponse; + setMaxHeight(resJson?.data?.aggregateBlock?._max?.height ?? null); + } catch (error) { + console.error("Failed to fetch max height:", error); + } + }; + void fetchMaxHeight(); + }, []); - setLoading(true); - const foundResults: SearchResult[] = []; + const searchIndexer = useCallback( + async (searchQuery: string) => { + if (!searchQuery) { + setResults([]); + return; + } - try { - const gqlQuery = `query Search($input: String!) { - blocks(where: { hash: { contains: $input } }, take: 10) { + setLoading(true); + const foundResults: SearchResult[] = []; + + try { + const isNumeric = /^\d+$/.test(searchQuery); + const searchHeight = isNumeric ? parseInt(searchQuery, 10) : null; + const shouldSearchByHeight = + searchHeight !== null && + maxHeight !== null && + searchHeight <= maxHeight; + const searchByHeightQuery = ` + blocks( + where: { height: { equals: $height } } + take: 10 + ) { hash height } - transactions(where: { hash: { contains: $input } }, take: 10) { + `; + const searchByHashQuery = ` + blocks( + where: { hash: { contains: $input } } + take: 10 + ) { + hash + height + } + `; + const gqlQuery = ` + query Search($input: String! ${shouldSearchByHeight ? ", $height: Int" : ""}) { + ${shouldSearchByHeight ? searchByHeightQuery : searchByHashQuery} + transactions( + where: { hash: { contains: $input } } + take: 10 + ) { hash methodId } - settlements(where: { transactionHash: { contains: $input } }, take: 10) { + + settlements( + where: { transactionHash: { contains: $input } } + take: 10 + ) { transactionHash promisedMessagesHash } - }`; - const queryRes = await fetch(`${config.INDEXER_URL}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: gqlQuery, - variables: { input: searchQuery }, - }), - }); - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ - const res: SearchResponse = (await queryRes.json()) as SearchResponse; - res?.data?.blocks?.forEach((block) => { - foundResults.push({ - type: "block", - hash: block.hash, - label: `Block #${block.height}`, + } + `; + const queryRes = await fetch(`${config.INDEXER_URL}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: gqlQuery, + variables: { + input: searchQuery, + height: shouldSearchByHeight ? searchHeight : undefined, + }, + }), }); - }); - res?.data?.transactions?.forEach((tx) => { - foundResults.push({ - type: "transaction", - hash: tx.hash, - label: `Transaction ${tx.hash.substring(0, 8)}...`, + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ + const res = (await queryRes.json()) as SearchResponse; + res?.data?.blocks.forEach((block) => { + foundResults.push({ + type: "block", + hash: block.hash, + label: `Block #${block.height}`, + }); }); - }); - res?.data?.settlements?.forEach((settlement) => { - foundResults.push({ - type: "settlement", - hash: settlement.transactionHash, - label: `Settlement ${settlement.transactionHash.substring(0, 8)}...`, + + res?.data?.transactions?.forEach((tx) => { + foundResults.push({ + type: "transaction", + hash: tx.hash, + label: `Transaction ${tx.hash.substring(0, 8)}...`, + }); }); - }); - setResults(foundResults); - } catch (error) { - console.error("Search error:", error); - } finally { - setLoading(false); - } - }, []); + + res?.data?.settlements?.forEach((settlement) => { + foundResults.push({ + type: "settlement", + hash: settlement.transactionHash, + label: `Settlement ${settlement.transactionHash.substring(0, 8)}...`, + }); + }); + + setResults(foundResults); + } catch (error) { + console.error("Search error:", error); + } finally { + setLoading(false); + } + }, + [maxHeight] + ); useEffect(() => { const timer = setTimeout(() => { @@ -108,7 +184,7 @@ export default function GlobalSearch() { }, 300); return () => clearTimeout(timer); - }, [query, searchIndexer]); + }, [query, searchIndexer, maxHeight]); const handleResultClick = (result: SearchResult) => { switch (result.type) { @@ -195,3 +271,4 @@ export default function GlobalSearch() { ); } +/* eslint-enable no-underscore-dangle */ diff --git a/packages/explorer/src/components/transactions/TransactionsPageClient.tsx b/packages/explorer/src/components/transactions/TransactionsPageClient.tsx index 4a4a9b366..f02206eb4 100644 --- a/packages/explorer/src/components/transactions/TransactionsPageClient.tsx +++ b/packages/explorer/src/components/transactions/TransactionsPageClient.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { z } from "zod"; +import { CircleCheck, CircleX } from "lucide-react"; import DataTable from "@/components/ui/DataTable"; import { FilterFieldDef } from "@/components/ui/FilterBuilder"; @@ -37,8 +38,7 @@ export interface TableItem { methodId: string; sender: string; nonce: string; - status: string; - statusMessage: string; + status: { isSuccess: boolean; message?: string }; } export const columns: Record = { @@ -47,7 +47,6 @@ export const columns: Record = { sender: "Sender", nonce: "Nonce", status: "Status", - statusMessage: "Status Message", }; const formSchema = z.object({ @@ -99,6 +98,24 @@ const graphqlQuery = `query GetTransactions($take: Int!, $skip: Int!, $where: Tr } }`; +const statusRenderer = (item: TableItem) => { + const { isSuccess, message } = item.status; + + return ( +
+ {isSuccess === true ? ( + + ) : ( + <> + + {message !== undefined && ( + {message} + )} + + )} +
+ ); +}; export default function TransactionsPageClient() { const [page, view, filters, setPage, setView, setFilters] = useQueryParams( columns, @@ -130,14 +147,22 @@ export default function TransactionsPageClient() { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions (await response.json()) as GetTransactionsQueryResponse; const transactions = result.data?.transactions; - const mappedItems: TableItem[] = transactions?.map((item) => ({ - hash: item.hash, - methodId: item.methodId, - sender: item.sender, - nonce: item.nonce, - status: item.executionResult.status ? "true" : "false", - statusMessage: item.executionResult.statusMessage ?? "—", - })); + const mappedItems: TableItem[] = transactions?.map((item) => { + const statusDisplay = + item.executionResult?.status === true + ? { isSuccess: true } + : { + isSuccess: false, + message: item.executionResult?.statusMessage ?? "Pending", + }; + return { + hash: item.hash, + methodId: item.methodId, + sender: item.sender, + nonce: item.nonce, + status: statusDisplay, + }; + }); setData(mappedItems); setTotalCount( @@ -170,7 +195,8 @@ export default function TransactionsPageClient() { onPageChange={setPage} onViewChange={setView} navigationPath="/transactions/{hash}" - copyKeys={["hash", "sender"]} + copyKeys={["hash", "sender", "methodId"]} + columnRenderers={{ status: statusRenderer }} /> ); } diff --git a/packages/explorer/src/components/ui/DataTable.tsx b/packages/explorer/src/components/ui/DataTable.tsx index 5f0744749..b77cdfa44 100644 --- a/packages/explorer/src/components/ui/DataTable.tsx +++ b/packages/explorer/src/components/ui/DataTable.tsx @@ -17,6 +17,9 @@ export interface DataTableConfig { columns: Record; navigationPath?: string; copyKeys?: string[]; + columnRenderers?: Partial< + Record React.ReactNode> + >; items?: TItem[]; totalCount?: string; loading?: boolean; @@ -36,6 +39,7 @@ export default function DataTable(config: DataTableConfig) { columns, navigationPath, copyKeys, + columnRenderers, items: externalItems, totalCount: externalTotalCount, loading: externalLoading, @@ -162,6 +166,7 @@ export default function DataTable(config: DataTableConfig) { view={currentView} onRowClick={() => handleRowClick(item)} copyKeys={copyKeys} + columnRenderers={columnRenderers} /> )} page={page != null ? page : 0} diff --git a/packages/explorer/src/components/ui/GenericTableRow.tsx b/packages/explorer/src/components/ui/GenericTableRow.tsx index 9fbadad63..0dd1d28ed 100644 --- a/packages/explorer/src/components/ui/GenericTableRow.tsx +++ b/packages/explorer/src/components/ui/GenericTableRow.tsx @@ -3,7 +3,7 @@ /* eslint-disable no-nested-ternary */ import React from "react"; -import { ChevronRight, CircleCheck, CircleX } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { TableCell, TableRow } from "./table"; import { Skeleton } from "./skeleton"; @@ -17,7 +17,9 @@ export interface GenericTableRowProps { loading: boolean; item: Item; copyKeys?: string[]; - statusKey?: string; + columnRenderers?: Partial< + Record React.ReactNode> + >; onRowClick?: () => void; } @@ -28,7 +30,7 @@ export default function GenericTableRow({ item, onRowClick, copyKeys = [], - statusKey, + columnRenderers, }: GenericTableRowProps) { return ( @@ -37,16 +39,10 @@ export default function GenericTableRow({ view.includes(_key) && ( {!loading ? ( - copyKeys.includes(_key) ? ( + columnRenderers && _key in columnRenderers ? ( + columnRenderers[typed(_key)]?.(item) + ) : copyKeys.includes(_key) ? ( (_key)])} /> - ) : statusKey === _key ? ( -
- {String(item[typed(_key)]) === "true" ? ( - - ) : ( - - )} -
) : ( <>{String(item[typed(_key)])} ) diff --git a/packages/explorer/src/config.ts b/packages/explorer/src/config.ts index 97529e1ab..05ded835e 100644 --- a/packages/explorer/src/config.ts +++ b/packages/explorer/src/config.ts @@ -1,5 +1,10 @@ const config = { - INDEXER_URL: "http://localhost:8081/graphql", + INDEXER_URL: + process.env.NEXT_PUBLIC_INDEXER_URL ?? "http://localhost:8081/graphql", + DASHBOARD_TITLE: process.env.NEXT_PUBLIC_DASHBOARD_TITLE ?? "Explorer", + DASHBOARD_SLOGAN: + process.env.NEXT_PUBLIC_DASHBOARD_SLOGAN ?? + "Explore the blockchain. Search in real-time.", }; export default config; diff --git a/packages/indexer/prisma/migrations/20260201142200_transaction_priority/migration.sql b/packages/indexer/prisma/migrations/20260201142200_transaction_priority/migration.sql new file mode 100644 index 000000000..e04752925 --- /dev/null +++ b/packages/indexer/prisma/migrations/20260201142200_transaction_priority/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "TransactionPriority" ( + "transactionHash" TEXT NOT NULL, + "priority" BIGINT NOT NULL, + + CONSTRAINT "TransactionPriority_pkey" PRIMARY KEY ("transactionHash") +); + +-- AddForeignKey +ALTER TABLE "TransactionPriority" ADD CONSTRAINT "TransactionPriority_transactionHash_fkey" FOREIGN KEY ("transactionHash") REFERENCES "Transaction"("hash") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/indexer/prisma/schema.prisma b/packages/indexer/prisma/schema.prisma index 88638875e..557665db9 100644 --- a/packages/indexer/prisma/schema.prisma +++ b/packages/indexer/prisma/schema.prisma @@ -44,10 +44,21 @@ model Transaction { isMessage Boolean executionResult TransactionExecutionResult? + priority TransactionPriority? IncomingMessageBatchTransaction IncomingMessageBatchTransaction[] } +model TransactionPriority { + transactionHash String + + priority BigInt + + Transaction Transaction @relation(fields: [transactionHash], references: [hash]) + + @@id([transactionHash]) +} + model TransactionExecutionResult { stateTransitions Json @db.Json status Boolean