diff --git a/package-lock.json b/package-lock.json index 8197ac31..cda509d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "deepmerge": "^4.3.1", "dexie": "^3.2.4", "echarts": "^5.4.2", + "http-status-codes": "^2.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.17.0", @@ -16520,6 +16521,11 @@ } } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==" + }, "node_modules/http2-wrapper": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz", @@ -39036,6 +39042,11 @@ "micromatch": "^4.0.2" } }, + "http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==" + }, "http2-wrapper": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz", diff --git a/package.json b/package.json index 9ab89428..96998054 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "deepmerge": "^4.3.1", "dexie": "^3.2.4", "echarts": "^5.4.2", + "http-status-codes": "^2.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.17.0", diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index 3314bbff..f60784df 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -1,7 +1,8 @@ /** @jsxImportSource @emotion/react */ -import { ReactNode } from "react"; +import { ReactNode, useEffect, useMemo } from "react"; import { Alert, AlertProps, AlertTitle } from "@mui/material"; import { css } from "@emotion/react"; +import { useRollbar } from "@rollbar/react"; const alertStyle = css` padding: 16px 22px; @@ -25,12 +26,34 @@ const detailsStyle = css` export type ErrorMessageProps = AlertProps & { message: ReactNode; - details?: ReactNode; + details?: unknown|unknown[]; + report?: boolean; showReported?: boolean; } export const ErrorMessage = (props: ErrorMessageProps) => { - const {message, details, showReported, ...alertProps} = props; + const {message, details, report, showReported = report, ...alertProps} = props; + + const rollbar = useRollbar(); + + const errors = useMemo( + () => Array.isArray(details) ? details : details ? [details] : [], + [details] + ); + + const detailsContent = useMemo(() => { + return errors + .map(it => it instanceof Error ? it.message : "Unknown error") + .join("\n\n"); + }, [errors]); + + console.log("details", detailsContent); + + useEffect(() => { + if (report) { + errors.forEach(it => rollbar.error(it as any)); + } + }, [errors, report, rollbar]); return ( @@ -40,10 +63,10 @@ export const ErrorMessage = (props: ErrorMessageProps) => { This error is logged. No need to report it. } - {details && + {detailsContent &&
Details -
{details}
+
{detailsContent}
}
diff --git a/src/components/InfoTable.tsx b/src/components/InfoTable.tsx index cb623b9b..6130e5f1 100644 --- a/src/components/InfoTable.tsx +++ b/src/components/InfoTable.tsx @@ -136,7 +136,7 @@ export type InfoTableProps = TableContai additionalData?: A; loading?: boolean; notFound?: boolean; - notFoundMessage?: string; + notFoundMessage?: ReactNode; error?: any; errorMessage?: string; children: ReactElement>|(ReactElement>|false|undefined|null)[]; @@ -167,8 +167,8 @@ export const InfoTable = (props: InfoTab return ( ); } diff --git a/src/components/ItemsTable.tsx b/src/components/ItemsTable.tsx index dd2c1c85..22b1693e 100644 --- a/src/components/ItemsTable.tsx +++ b/src/components/ItemsTable.tsx @@ -131,8 +131,8 @@ export const ItemsTable = ); } diff --git a/src/components/account/AccountBalancesTable.tsx b/src/components/account/AccountBalancesTable.tsx index cbcf6d96..0b38b2ab 100644 --- a/src/components/account/AccountBalancesTable.tsx +++ b/src/components/account/AccountBalancesTable.tsx @@ -181,9 +181,10 @@ export const AccountBalancesTable = (props: AccountBalancesTableProps) => { } {balance.error && } diff --git a/src/components/account/AccountPortfolio.tsx b/src/components/account/AccountPortfolio.tsx index b2934f04..9bfb3d84 100644 --- a/src/components/account/AccountPortfolio.tsx +++ b/src/components/account/AccountPortfolio.tsx @@ -123,8 +123,8 @@ export const AccountPortfolio = (props: AccountPortfolioProps) => { return ( ); } diff --git a/src/components/network/NetworkStats.tsx b/src/components/network/NetworkStats.tsx index f9ea3e07..8b4a9496 100644 --- a/src/components/network/NetworkStats.tsx +++ b/src/components/network/NetworkStats.tsx @@ -99,8 +99,8 @@ export const NetworkStats = (props: NetworkInfoTableProps) => { return ( ); } diff --git a/src/components/network/NetworkTokenDistribution.tsx b/src/components/network/NetworkTokenDistribution.tsx index c5add34a..50f661fa 100644 --- a/src/components/network/NetworkTokenDistribution.tsx +++ b/src/components/network/NetworkTokenDistribution.tsx @@ -63,8 +63,8 @@ export const NetworkTokenDistribution = (props: NetworkTokenDistributionProps) = return ( ); } diff --git a/src/hooks/useResource.ts b/src/hooks/useResource.ts index 78b5f92f..dae70769 100644 --- a/src/hooks/useResource.ts +++ b/src/hooks/useResource.ts @@ -1,9 +1,8 @@ import { useEffect, useMemo } from "react"; import useSwr, { SWRConfiguration } from "swr"; -import { useRollbar } from "@rollbar/react"; import { Resource } from "../model/resource"; -import { DataError } from "../utils/error"; +import { NonFatalError } from "../utils/error"; export interface UseResourceOptions extends SWRConfiguration { /** @@ -25,8 +24,6 @@ export function useResource( args: F, options: UseResourceOptions = {} ) { - const rollbar = useRollbar(); - const {skip, refresh, refreshInterval = 3000, ...swrOptions} = options; const swrKey = !skip @@ -39,13 +36,7 @@ export function useResource( }); useEffect(() => { - if (!error) { - return; - } - - if (error && error instanceof DataError) { - rollbar.error(error); - } else { + if (error && !(error instanceof NonFatalError)) { throw error; } }, [error]); diff --git a/src/model/searchResult.ts b/src/model/searchResult.ts index 62777053..c8f0790d 100644 --- a/src/model/searchResult.ts +++ b/src/model/searchResult.ts @@ -1,3 +1,5 @@ +import { NetworkError } from "../utils/error"; + import { ItemsResponse } from "./itemsResponse"; import { SearchResultItem } from "./searchResultItem"; @@ -11,5 +13,6 @@ export type SearchResult = { blocks: ItemsResponse, true> extrinsics: ItemsResponse, true> events: ItemsResponse, true> + errors?: NetworkError[]; totalCount: number; } diff --git a/src/rollbar.ts b/src/rollbar.ts index 2bbbc12f..e75d2138 100644 --- a/src/rollbar.ts +++ b/src/rollbar.ts @@ -22,4 +22,26 @@ export const rollbarConfig: Configuration = { enabled: config.rollbar.enabled }; -export const rollbar = new Rollbar(rollbarConfig); +const rollbar = new Rollbar(rollbarConfig); + +if (!config.rollbar.enabled && window.location.hostname === "localhost") { + // if the rollbar is disabled on localhost, print + // the message about reporting to the console + // so it is obvious when it would happen + + const notifier = (rollbar as any).client.notifier; + const log = notifier.log; + + notifier.log = function (item: any, callback: any) { + console.warn(`The following ${item.level} item would be reported to Rollbar`); + + if (!item._isUncaught) { + const consoleAction = ["error", "critical"].includes(item.level) ? "error" : "warn"; + console[consoleAction](...item._originalArgs); + } + + log.apply(this, [item, callback]); + }; +} + +export { rollbar }; diff --git a/src/router.tsx b/src/router.tsx index aca04ddb..d758e13f 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -29,6 +29,7 @@ export const routes: RouteObject[] = [ { path: "search/:tab?", element: , + errorElement: , }, { id: "network", diff --git a/src/screens/error.tsx b/src/screens/error.tsx index 05fa72c4..521442fe 100644 --- a/src/screens/error.tsx +++ b/src/screens/error.tsx @@ -20,7 +20,8 @@ export const ErrorPage = () => { Error diff --git a/src/screens/search.tsx b/src/screens/search.tsx index fda0e786..417e2946 100644 --- a/src/screens/search.tsx +++ b/src/screens/search.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @emotion/react */ -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { Navigate, useSearchParams } from "react-router-dom"; import { css } from "@emotion/react"; @@ -36,6 +36,51 @@ const queryStyle = css` } `; +const xxStyle = css` + line-height: 38px; +`; + +const queryStyle2 = css` + padding: 4px 8px; + margin: 0 2px; + + font-family: inherit; + background-color: #f5f5f5; + border-radius: 6px; + + &::before { + content: open-quote; + } + + &::after { + content: close-quote; + } +`; + +const networkStyle = css` + padding: 4px 8px; + background-color: #f5f5f5; + border-radius: 8px; + + white-space: nowrap; +`; + +const iconStyle = css` + width: 20px; + height: 20px; + + border-radius: 0px; + margin-right: 4px; + + vertical-align: text-bottom; + position: relative; + top: -1px; +`; + +const errorStyle = css` + margin-top: 32px; +`; + const loadingStyle = css` text-align: center; word-break: break-all; @@ -146,76 +191,107 @@ export const SearchPage = () => { Unexpected error occured while searching for {query}} - details={searchResult.error.message} - showReported + details={searchResult.error} + report /> ); } return ( - - - Search results for query {query} - - - - - - - - - - - - - + + + Search results + +
+ For query {query} in {" "} + {networkNames.length === 0 && all networks.} + {networkNames.length > 0 && ( + <> + {getNetworks(networkNames).slice(0, 5).map((it, index) => + + + {it.displayName} + + {index <= networkNames.length - 3 && , } + {index === networkNames.length - 2 && and } + + )} network{networkNames.length > 1 && s}. + + )} +
+ {searchResult.data?.errors && searchResult.data?.errors.length > 0 && ( + Unexpected error occured while searching some networks} + details={searchResult.data.errors} + report /> -
-
-
+ )} + + {searchResult.data && + + + + + + + + + + + + + + + + + } + ); }; diff --git a/src/services/searchService.ts b/src/services/searchService.ts index 3430ce02..851cf4a8 100644 --- a/src/services/searchService.ts +++ b/src/services/searchService.ts @@ -11,21 +11,22 @@ import { Extrinsic } from "../model/extrinsic"; import { ItemsConnection } from "../model/itemsConnection"; import { ItemsResponse } from "../model/itemsResponse"; import { Network } from "../model/network"; +import { PaginationOptions } from "../model/paginationOptions"; +import { SearchResult } from "../model/searchResult"; +import { SearchResultItem } from "../model/searchResultItem"; import { decodeAddress, encodeAddress, isAccountPublicKey, isEncodedAddress } from "../utils/address"; import { warningAssert } from "../utils/assert"; +import { NetworkError, NonFatalError } from "../utils/error"; import { extractConnectionItems, paginationToConnectionCursor } from "../utils/itemsConnection"; import { emptyItemsResponse } from "../utils/itemsResponse"; +import { PickByType } from "../utils/types"; import { BlocksFilter, blocksFilterToExplorerSquidFilter, unifyExplorerSquidBlock } from "./blocksService"; import { EventsFilter, addEventsArgs, eventsFilterToExplorerSquidFilter, normalizeEventName, unifyExplorerSquidEvent } from "./eventsService"; import { ExtrinsicsFilter, extrinsicFilterToExplorerSquidFilter, normalizeExtrinsicName, unifyExplorerSquidExtrinsic } from "./extrinsicsService"; import { fetchExplorerSquid } from "./fetchService"; import { getNetwork, getNetworks, hasSupport } from "./networksService"; -import { PaginationOptions } from "../model/paginationOptions"; -import { PickByType } from "../utils/types"; -import { SearchResult } from "../model/searchResult"; -import { SearchResultItem } from "../model/searchResultItem"; export type SearchPaginationOptions = Record>, PaginationOptions>; @@ -45,6 +46,23 @@ export async function search(query: string, networks: Network[], pagination: Sea .map((it) => it.status === "fulfilled" ? it.value : undefined) .filter((it): it is NetworkSearchResult => !!it); + const errors = promiseResults + .map((it, index) => { + if (it.status !== "rejected") { + return undefined; + } + + if (it.reason instanceof NonFatalError) { + return new NetworkError(networks[index]!, it.reason); + } + + throw it.reason; + }) + .filter((it): it is NetworkError => !!it); + + console.log("results", networkResults); + console.log("errors", errors); + let nonEmptyNetworkResults = networkResults.filter((result) => result.totalCount > 0); if (isAccountPublicKey(query) && nonEmptyNetworkResults.length === 0) { @@ -112,6 +130,7 @@ export async function search(query: string, networks: Network[], pagination: Sea blocks, extrinsics, events, + errors, totalCount }; } diff --git a/src/utils/error.ts b/src/utils/error.ts index b486078f..1bb807c5 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,7 +1,34 @@ import { CustomError } from "ts-custom-error"; +import { getReasonPhrase } from "http-status-codes"; -export class DataError extends CustomError { +import { Network } from "../model/network"; + +export class NonFatalError extends CustomError { + constructor(message: string) { + super(message); + } +} + +export class DataError extends NonFatalError { constructor(message: string) { super(message); } } + +export class FetchError extends NonFatalError { + constructor( + public readonly url: string, + public readonly code: number + ) { + super(`Cannot fetch ${url}: HTTP ${code} (${getReasonPhrase(code)})`); + } +} + +export class NetworkError extends NonFatalError { + constructor( + public readonly network: Network, + public readonly error: any + ) { + super(`${network.displayName}: ${"message" in error ? error.message : "Unknown error"}`); + } +} diff --git a/src/utils/fetchGraphql.ts b/src/utils/fetchGraphql.ts index e9e61427..d1213a3b 100644 --- a/src/utils/fetchGraphql.ts +++ b/src/utils/fetchGraphql.ts @@ -1,4 +1,4 @@ -import { DataError } from "./error"; +import { DataError, FetchError } from "./error"; export async function fetchGraphql( url: string, @@ -18,6 +18,12 @@ export async function fetchGraphql( }), }); + const statusCode = response.status; + + if (statusCode !== 200) { + throw new FetchError(url, statusCode); + } + const jsonResult = await response.json(); if (jsonResult.errors && !jsonResult.data) {