diff --git a/.github/workflows/chromatic.yaml b/.github/workflows/chromatic.yaml index 938f9d709..22e181d5b 100644 --- a/.github/workflows/chromatic.yaml +++ b/.github/workflows/chromatic.yaml @@ -3,7 +3,9 @@ on: push concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - +env: + NEXT_PUBLIC_CARTESI_NODE_RPC_URL: http://127.0.0.1:10011/rpc + NEXT_PUBLIC_MOCK_ENABLED: true jobs: chromatic-deployment: runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index 039f1d4aa..4ee6753d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,5 @@ node_modules .storybook graphql contracts.ts +**/src/generated/** **/rollups-wagmi/src/index.tsx \ No newline at end of file diff --git a/apps/dave/.gitignore b/apps/dave/.gitignore index 25a0750a7..2a27df93d 100644 --- a/apps/dave/.gitignore +++ b/apps/dave/.gitignore @@ -25,6 +25,7 @@ src/generated *.njsproj *.sln *.sw? +next-env.d.ts *storybook.log storybook-static diff --git a/apps/dave/.storybook/preview.tsx b/apps/dave/.storybook/preview.tsx index 663e954b0..2652230bb 100644 --- a/apps/dave/.storybook/preview.tsx +++ b/apps/dave/.storybook/preview.tsx @@ -1,10 +1,11 @@ -import { MantineProvider } from "@mantine/core"; +import { useMantineColorScheme } from "@mantine/core"; import "@mantine/core/styles.css"; -import { Notifications } from "@mantine/notifications"; import type { Preview, StoryContext, StoryFn } from "@storybook/nextjs"; +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { UPDATE_GLOBALS } from "storybook/internal/core-events"; +import { addons, useGlobals } from 'storybook/preview-api'; import Layout from "../src/components/layout/Layout"; import { Providers } from '../src/providers/Providers'; -import theme from "../src/providers/theme"; import './global.css'; try { @@ -17,6 +18,8 @@ try { console.info((error as Error).message) } +type Globals = ReturnType[0] + const withLayout = (StoryFn: StoryFn, context: StoryContext) => { const { title } = context; const [sectionType] = title.split("/"); @@ -27,22 +30,71 @@ const withLayout = (StoryFn: StoryFn, context: StoryContext) => { return <>{StoryFn(context.args, context)}; }; -const withProviders = (StoryFn: StoryFn, context: StoryContext) => { - return {StoryFn(context.args, context)} +const withProviders = (StoryFn: StoryFn, context: StoryContext) => { + return ( + + + {StoryFn(context.args, context)} + + + ) } -const withMantine = (StoryFn: StoryFn, context: StoryContext) => { - const currentBg = context.globals.backgrounds?.value ?? "light"; +const channel = addons.getChannel(); - return ( - - - {StoryFn(context.args, context)} - - ); -}; +const generateNewBackgroundEvt = (colorScheme: unknown) => ({globals: { backgrounds: {value: colorScheme, grid: false}}}) + +// eslint-disable-next-line react-refresh/only-export-components +function ColorSchemeWrapper({ children, context}: { children: ReactNode, context: StoryContext }) { + const { colorScheme, setColorScheme } = useMantineColorScheme(); + const [latestGlobalsBg, setLatestGlobalBg] = useState(colorScheme); + + const handleColorScheme = useCallback(({ globals }: { globals: Globals }) => { + const bgValue = globals.backgrounds?.value + const newMode = bgValue ?? 'light'; + if(newMode !== colorScheme) { + setColorScheme(newMode); + setLatestGlobalBg(newMode); + } else if (newMode !== latestGlobalsBg) { + setLatestGlobalBg(newMode) + } + // update the handler function every time both variables change + // as the handler is outside of React's detection. We want + // to make sure the handler works with fresh values. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorScheme, latestGlobalsBg]); + + + useEffect(() => { + // Only when on story mode i.e. not on autodocs view. + // Due to the many re-renders until its finished. That cause slow but infinite loops. + if(context.viewMode === 'story') { + // on-mount emit single event to sync whatever is the default color-scheme on mantine + channel.emit(UPDATE_GLOBALS, generateNewBackgroundEvt(colorScheme)) + } + }, []) + + useEffect(() => { + channel.on("updateGlobals", handleColorScheme); + return () => { + // unsubscribe to subscribe again with fresher handler. + channel.off("updateGlobals", handleColorScheme); + } + }, [handleColorScheme]); + + useEffect(() => { + if(colorScheme !== latestGlobalsBg) { + console.log('color-scheme new value came from App ui interaction. emitting event to sync.'); + channel.emit(UPDATE_GLOBALS, generateNewBackgroundEvt(colorScheme)); + } + }, [colorScheme, latestGlobalsBg]) + + + + return <>{children}; +} -const preview: Preview = { +const preview: Preview = { initialGlobals: { backgrounds: { value: "light" }, }, @@ -64,10 +116,10 @@ const preview: Preview = { }, }, decorators: [ - // Order matters. So layout decorator first. Fn calling is router(mantine(layout)) - withLayout, + // Order matters. So layout decorator first. Fn calling is providers(layout(Story)) + withLayout, withProviders, - withMantine, + ], }; diff --git a/apps/dave/eslint.config.mjs b/apps/dave/eslint.config.mjs index ad3865181..fe3f72e17 100644 --- a/apps/dave/eslint.config.mjs +++ b/apps/dave/eslint.config.mjs @@ -6,6 +6,9 @@ import globals from "globals"; /** @type {import("eslint").Linter.Config[]} */ export default [ ...reactInternal, + // ...nextJsConfig, + + { ignores: ["coverage/**", ".turbo/**", "public/**", ".next/**"] }, { files: ["**/*.{ts,tsx}"], languageOptions: { diff --git a/apps/dave/mantine.d.ts b/apps/dave/mantine.d.ts index c0d8b9140..e14c963b5 100644 --- a/apps/dave/mantine.d.ts +++ b/apps/dave/mantine.d.ts @@ -8,9 +8,20 @@ type ExtendedCustomColors = | core.DefaultMantineColor; declare module "@mantine/core" { export { core }; + + /** + * Making it optional as default values to the + * declared interface members are added in the theme. + */ + export interface SpoilerProps extends core.SpoilerProps { + hideLabel?: core.SpoilerProps["hideLabel"]; + showLabel?: core.SpoilerProps["showLabel"]; + } export interface MantineThemeOther { lgIconSize: number; mdIconSize: number; + smIconSize: number; + xsIconSize: number; zIndexXS: number; zIndexSM: number; zIndexMD: number; diff --git a/apps/dave/next-env.d.ts b/apps/dave/next-env.d.ts deleted file mode 100644 index 9edff1c7c..000000000 --- a/apps/dave/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dave/package.json b/apps/dave/package.json index 5571904f0..74d9d5dac 100644 --- a/apps/dave/package.json +++ b/apps/dave/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf src/generated", "dev": "next dev", "build": "next build", "start": "next start", @@ -19,18 +19,24 @@ "dependencies": { "@cartesi/viem": "2.0.0-alpha.26", "@cartesi/wagmi": "2.0.0-alpha.30", - "@mantine/core": "^8.3.13", - "@mantine/form": "^8.3.13", - "@mantine/hooks": "^8.3.13", - "@mantine/notifications": "^8.3.13", + "@mantine/code-highlight": "^8.3.15", + "@mantine/core": "^8.3.15", + "@mantine/form": "^8.3.15", + "@mantine/hooks": "^8.3.15", + "@mantine/notifications": "^8.3.15", "@rainbow-me/rainbowkit": "^2.2.10", "@raugfer/jazzicon": "^1.0.6", + "@react-spring/web": "^10.0.1", + "@shazow/whatsabi": "^0.14.1", "@tabler/icons-react": "^3.35.0", "@tanstack/react-query": "catalog:", "@vercel/analytics": "^1.6.1", + "abitype": "1.2.3", "date-fns": "^4.1.0", + "dexie": "^4.0.11", "humanize-duration": "^3.33.2", - "next": "^16.1.5", + "jotai": "^2.12.5", + "next": "^16.1.6", "pretty-ms": "^8", "ramda": "^0.32.0", "ramda-adjunct": "^5.1.0", @@ -38,6 +44,8 @@ "react-dom": "catalog:", "react-icons": "^5.5.0", "react-jazzicon": "^1", + "semver": "^7.7.4", + "uuid": "^11.1.0", "viem": "catalog:", "wagmi": "catalog:" }, @@ -53,6 +61,7 @@ "@types/ramda": "^0.31.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/semver": "^7.7.1", "@wagmi/cli": "^2.1.2", "eslint": "catalog:", "eslint-config-cartesi": "workspace:*", diff --git a/apps/dave/src/app/apps/[application]/page.tsx b/apps/dave/src/app/apps/[application]/page.tsx new file mode 100644 index 000000000..560b821e8 --- /dev/null +++ b/apps/dave/src/app/apps/[application]/page.tsx @@ -0,0 +1,6 @@ +import { ApplicationSummaryContainer } from "../../../containers/ApplicationSummaryContainer"; + +export default async function Page(props: PageProps<"/apps/[application]">) { + const { application } = await props.params; + return ; +} diff --git a/apps/dave/src/app/layout.tsx b/apps/dave/src/app/layout.tsx index bcde12bf0..e8ee0749c 100644 --- a/apps/dave/src/app/layout.tsx +++ b/apps/dave/src/app/layout.tsx @@ -1,5 +1,7 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import "@mantine/core/styles.css"; +// prettier-ignore code-highlight styles must be imported after the core. +import "@mantine/code-highlight/styles.css"; import "@mantine/notifications/styles.css"; import { Analytics } from "@vercel/analytics/react"; import type { FC, ReactNode } from "react"; diff --git a/apps/dave/src/app/specifications/edit/[id]/page.tsx b/apps/dave/src/app/specifications/edit/[id]/page.tsx new file mode 100644 index 000000000..9b4763a9b --- /dev/null +++ b/apps/dave/src/app/specifications/edit/[id]/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import { EditSpecificationContainer } from "../../../../containers/EditSpecificationContainer"; + +export const metadata: Metadata = { + title: "Edit Specifications", +}; + +export default async function Page( + props: PageProps<"/specifications/edit/[id]">, +) { + const params = await props.params; + + return ; +} diff --git a/apps/dave/src/app/specifications/new/page.tsx b/apps/dave/src/app/specifications/new/page.tsx new file mode 100644 index 000000000..b42dbd099 --- /dev/null +++ b/apps/dave/src/app/specifications/new/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { NewSpecificationContainer } from "../../../containers/NewSpecificationContainer"; + +export const metadata: Metadata = { + title: "New Specification", +}; + +export default function Page() { + return ; +} diff --git a/apps/dave/src/app/specifications/page.tsx b/apps/dave/src/app/specifications/page.tsx new file mode 100644 index 000000000..3a1e34aac --- /dev/null +++ b/apps/dave/src/app/specifications/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { SpecificationsContainer } from "../../containers/SpecificationsContainer"; + +export const metadata: Metadata = { + title: "Specifications", +}; + +export default function Page() { + return ; +} diff --git a/apps/dave/src/components/CenteredText.tsx b/apps/dave/src/components/CenteredText.tsx new file mode 100644 index 000000000..9a4021ef8 --- /dev/null +++ b/apps/dave/src/components/CenteredText.tsx @@ -0,0 +1,28 @@ +import { + Card, + Center, + Text, + type CardProps, + type TextProps, +} from "@mantine/core"; +import type { FC } from "react"; + +interface CenteredTextProps { + text: string; + cardProps?: CardProps; + textProps?: TextProps; +} + +const CenteredText: FC = (props) => { + return ( + +
+ + {props.text} + +
+
+ ); +}; + +export default CenteredText; diff --git a/apps/dave/src/components/ConnectWallet.tsx b/apps/dave/src/components/ConnectWallet.tsx new file mode 100644 index 000000000..030474f0f --- /dev/null +++ b/apps/dave/src/components/ConnectWallet.tsx @@ -0,0 +1,157 @@ +"use client"; +import { + Avatar, + Badge, + Button, + Group, + Text, + useMantineTheme, + type ButtonProps, + type MantineSize, +} from "@mantine/core"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { Activity, useState, type FC } from "react"; +import { TbCaretDownFilled } from "react-icons/tb"; +import { type Hex } from "viem"; +import { HashAvatar } from "./HashAvatar"; + +type Size = number | MantineSize | string; + +interface ConnectWalletProps { + showChain?: boolean; + showBalance?: boolean; + size?: ButtonProps["size"]; +} + +const AccountAvatar: FC<{ + address: Hex; + ensUri?: string; + size?: Size; +}> = ({ address, ensUri, size }) => { + const [ensFailed, setEnsFailed] = useState(false); + if (ensUri && !ensFailed) { + return ( + { + setEnsFailed(true); + }} + /> + ); + } + + return ; +}; + +export const ConnectWallet: FC = ({ + showChain = false, + showBalance = true, + size = "sm", +}) => { + const theme = useMantineTheme(); + return ( + + {({ + account, + chain, + openAccountModal, + openChainModal, + openConnectModal, + mounted, + }) => { + const connected = mounted && account && chain; + const hasBalanceToShow = showBalance && account?.displayBalance; + + if (!connected) { + return ( + + ); + } + + if (chain.unsupported) { + return ( + + ); + } + + return ( + + + + + + + ); + }} + + ); +}; diff --git a/apps/dave/src/components/CopyButton.tsx b/apps/dave/src/components/CopyButton.tsx index d6c551227..9a5d15843 100644 --- a/apps/dave/src/components/CopyButton.tsx +++ b/apps/dave/src/components/CopyButton.tsx @@ -9,9 +9,10 @@ import { TbCheck, TbCopy } from "react-icons/tb"; interface CopyButtonProps { value: string; + disableTooltip?: boolean; } -const CopyButton: FC = ({ value }) => { +const CopyButton: FC = ({ value, disableTooltip = false }) => { return ( {({ copied, copy }) => ( @@ -19,6 +20,7 @@ const CopyButton: FC = ({ value }) => { label={copied ? "Copied" : "Copy"} withArrow position="right" + disabled={disableTooltip} > { + content: string | { [key: string]: unknown }; +} + +const JSONViewer: FC = ({ + content, + variant = "filled", + size = "md", + id, + placeholder = "No content defined", +}) => { + const value = isString(content) ? content : stringifyContent(content); + return ( + evt.currentTarget.blur()} + placeholder={placeholder} + formatOnBlur + /> + ); +}; + +export default JSONViewer; diff --git a/apps/dave/src/components/LabelWithTooltip.tsx b/apps/dave/src/components/LabelWithTooltip.tsx new file mode 100644 index 000000000..b0da2a6a7 --- /dev/null +++ b/apps/dave/src/components/LabelWithTooltip.tsx @@ -0,0 +1,37 @@ +import { Flex, Group, Text, Tooltip, type TooltipProps } from "@mantine/core"; +import { type FC } from "react"; +import { TbHelp } from "react-icons/tb"; + +interface Props { + label: string; + tooltipLabel: string; + tooltipProps?: Partial>; +} + +/** + * + * Component to be used in conjuction with Mantine input type components + * to be passed as the Label value when besides description a tooltip is + * also desired. + */ +const LabelWithTooltip: FC = ({ label, tooltipLabel, tooltipProps }) => { + const toolProps = tooltipProps ?? {}; + return ( + + {label} + + + + + + + ); +}; + +export default LabelWithTooltip; diff --git a/apps/dave/src/components/QueryPagination.tsx b/apps/dave/src/components/QueryPagination.tsx index 449852896..f742e5028 100644 --- a/apps/dave/src/components/QueryPagination.tsx +++ b/apps/dave/src/components/QueryPagination.tsx @@ -1,5 +1,6 @@ import type { Pagination as QPagination } from "@cartesi/viem"; import { Group, Pagination, type GroupProps } from "@mantine/core"; +import { isNil } from "ramda"; import type { FC } from "react"; const getActivePage = (offset: number, limit: number) => { @@ -7,6 +8,11 @@ const getActivePage = (offset: number, limit: number) => { return offset / safeLimit + 1; }; +const getTotalPages = (totalCount: number, limit: number) => { + const denominator = limit === 0 || isNil(limit) ? 1 : limit; + return Math.ceil(totalCount / denominator); +}; + type QueryPaginationProps = { pagination: QPagination; onPaginationChange?: (newOffset: number) => void; @@ -20,11 +26,12 @@ export const QueryPagination: FC = ({ groupProps, hideIfSinglePage = true, }) => { - const totalPages = Math.ceil(pagination.totalCount / pagination.limit); + const totalPages = getTotalPages(pagination.totalCount, pagination.limit); const activePage = getActivePage(pagination.offset, pagination.limit); - const displayPagination = totalPages > 1 && hideIfSinglePage; + const hasNoPages = totalPages === 0; + const isSinglePage = totalPages === 1; - if (!displayPagination) return ""; + if (hasNoPages || (isSinglePage && hideIfSinglePage)) return ""; return ( diff --git a/apps/dave/src/components/SummaryCard.tsx b/apps/dave/src/components/SummaryCard.tsx new file mode 100644 index 000000000..b8716f1c6 --- /dev/null +++ b/apps/dave/src/components/SummaryCard.tsx @@ -0,0 +1,44 @@ +"use client"; +import { Card, Flex, Skeleton, Text } from "@mantine/core"; +import { type FC } from "react"; +import { type IconType } from "react-icons"; +import TweenedNumber from "./TweenedNumber"; + +export type SummaryCardProps = { + icon?: IconType; + title: string; + value: number; + displaySkeleton: boolean; +}; + +const SummarySkeletonCard = () => ( + + + + + + +); + +export const SummaryCard: FC = (props) => { + if (props.displaySkeleton) return ; + + return ( + + + {props.icon && ( + + )} + + {props.title} + + + + + + + ); +}; diff --git a/apps/dave/src/components/ThemeToggle.tsx b/apps/dave/src/components/ThemeToggle.tsx index bfe631756..da847a277 100644 --- a/apps/dave/src/components/ThemeToggle.tsx +++ b/apps/dave/src/components/ThemeToggle.tsx @@ -1,26 +1,34 @@ "use client"; -import { Switch, useMantineColorScheme, VisuallyHidden } from "@mantine/core"; +import { + Switch, + Text, + useMantineColorScheme, + VisuallyHidden, +} from "@mantine/core"; import type { FC } from "react"; -import { TbMoonStars, TbSun } from "react-icons/tb"; export const ThemeToggle: FC = () => { const { colorScheme, toggleColorScheme } = useMantineColorScheme(); return ( Theme mode switch} - checked={colorScheme === "light"} + aria-label="Theme mode switch" + checked={colorScheme === "dark"} onChange={() => toggleColorScheme()} size="md" onLabel={ <> Dark Mode - + + on + } offLabel={ <> Light Mode - + + off + } /> diff --git a/apps/dave/src/components/TweenedNumber.tsx b/apps/dave/src/components/TweenedNumber.tsx new file mode 100644 index 000000000..807be0924 --- /dev/null +++ b/apps/dave/src/components/TweenedNumber.tsx @@ -0,0 +1,19 @@ +"use client"; +import { animated, useSpring } from "@react-spring/web"; + +interface TweenedNumber { + value: number; +} + +const TweenedNumber = ({ value }: TweenedNumber) => { + const { number } = useSpring({ + from: { number: 0 }, + number: value, + delay: 200, + config: { mass: 1, tension: 20, friction: 10 }, + }); + + return {number.to((n) => n.toFixed(0))}; +}; + +export default TweenedNumber; diff --git a/apps/dave/src/components/application/ApplicationCard.tsx b/apps/dave/src/components/application/ApplicationCard.tsx index 59cdce401..7feef846c 100644 --- a/apps/dave/src/components/application/ApplicationCard.tsx +++ b/apps/dave/src/components/application/ApplicationCard.tsx @@ -2,8 +2,8 @@ import type { Application, ApplicationState } from "@cartesi/viem"; import { Badge, Card, Group, Stack, Text } from "@mantine/core"; import Link from "next/link"; import { Activity, type FC } from "react"; -import { useAppConfig } from "../../providers/AppConfigProvider"; import { pathBuilder } from "../../routes/routePathBuilder"; +import { useSelectedNodeConnection } from "../connection/hooks"; import SendMenu from "../send/SendMenu"; type ApplicationCardProps = { application: Application }; @@ -25,8 +25,8 @@ export const ApplicationCard: FC = ({ application }) => { const { applicationAddress, consensusType, name, processedInputs, state } = application; const stateColour = getStateColour(state); - const appConfig = useAppConfig(); - const url = pathBuilder.epochs({ application: application.name }); + const selectedConnection = useSelectedNodeConnection(); + const url = pathBuilder.application({ application: application.name }); const inputsLabel = processedInputs === 0n ? "no inputs" @@ -47,7 +47,11 @@ export const ApplicationCard: FC = ({ application }) => { diff --git a/apps/dave/src/components/connection/ConnectionContexts.tsx b/apps/dave/src/components/connection/ConnectionContexts.tsx new file mode 100644 index 000000000..9703f6b5a --- /dev/null +++ b/apps/dave/src/components/connection/ConnectionContexts.tsx @@ -0,0 +1,72 @@ +"use client"; +import { createContext, type ActionDispatch } from "react"; +import type { ViewControl } from "./ConnectionModal"; +import IndexedDbRepository from "./indexedDbRepository"; +import type { DbNodeConnectionConfig, Repository } from "./types"; + +// Actions +type CloseModal = { type: "close_modal" }; +type OpenModal = { type: "open_modal"; payload?: ViewControl }; +type SetFetching = { type: "set_fetching"; payload: boolean }; +type AddConnection = { + type: "add_connection"; + payload: { connection: DbNodeConnectionConfig }; +}; +type RemoveConnection = { type: "remove_connection"; payload: { id: number } }; +type SetSelectedConnection = { + type: "set_selected_connection"; + payload: { id: number }; +}; +type SetSystemConnection = { + type: "set_system_connection"; + payload: { connection: DbNodeConnectionConfig }; +}; +type SetConnections = { + type: "set_connections"; + payload: { connections: DbNodeConnectionConfig[] }; +}; + +export type ConnectionState = { + systemConnection: DbNodeConnectionConfig | null; + selectedConnection: number | null; + repository: Repository; + connections: Record; + showConnectionModal: boolean; + connectionModalMode: ViewControl; + fetching: boolean; +}; + +export type ConnectionAction = + | SetSelectedConnection + | CloseModal + | OpenModal + | SetFetching + | AddConnection + | RemoveConnection + | SetConnections + | SetSystemConnection; + +export type ConnectionReducer = ( + state: ConnectionState, + action: ConnectionAction, +) => ConnectionState; + +export const initState = (repository: Repository): ConnectionState => { + return { + connections: {}, + fetching: true, + repository, + selectedConnection: null, + systemConnection: null, + showConnectionModal: false, + connectionModalMode: "manage", + }; +}; + +export const ConnectionStateContext = createContext( + initState(new IndexedDbRepository()), +); + +export const ConnectionActionContext = createContext< + ActionDispatch<[action: ConnectionAction]> +>(() => null); diff --git a/apps/dave/src/components/connection/ConnectionForm.tsx b/apps/dave/src/components/connection/ConnectionForm.tsx new file mode 100644 index 000000000..6ee01ecad --- /dev/null +++ b/apps/dave/src/components/connection/ConnectionForm.tsx @@ -0,0 +1,508 @@ +"use client"; +import { + Badge, + Button, + Card, + Divider, + Flex, + Group, + Loader, + Stack, + Switch, + Text, + TextInput, + useMantineTheme, +} from "@mantine/core"; +import { useForm, type FormErrors } from "@mantine/form"; +import { useDebouncedValue } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { isEmpty, isNil, isNotNil, path, toPairs } from "ramda"; +import React, { useEffect, useRef, useState, type FC } from "react"; +import { + TbCircleCheckFilled, + TbCircleXFilled, + TbPlugConnected, + TbPlugConnectedX, +} from "react-icons/tb"; +import { createPublicClient, http, type Chain } from "viem"; +import useRightColorShade from "../../hooks/useRightColorShade"; +import { checkChainId } from "../../lib/supportedChains"; +import { checkNodeVersion } from "../../lib/supportedRollupsNode"; +import { checkURL } from "../../lib/urlUtils"; +import { useGetNodeInformation, useNodeConnection } from "./hooks"; +import type { NodeConnectionConfig } from "./types"; + +interface ConnectionFormProps { + onConnectionSaved?: () => void; +} + +type ChainRpcHealthCheck = + | { + status: "success"; + blocknumber: bigint; + } + | { + status: "error"; + error: Error; + } + | { + status: "idle"; + } + | { + status: "pending"; + }; + +const useChainRpcHealthCheck = ( + url: string, + chain: Chain | null, +): ChainRpcHealthCheck => { + const [result, setResult] = useState({ + status: "idle", + }); + + useEffect(() => { + const urlCheck = checkURL(url); + if (!urlCheck.validUrl || isNil(chain)) return; + + setResult({ status: "pending" }); + const abortController = new AbortController(); + const publicClient = createPublicClient({ + chain, + transport: http(url, {}), + }); + + publicClient + .getBlockNumber() + .then((blocknumber) => { + if (abortController.signal.aborted) { + console.info( + `skipping blocknumber return: ${abortController.signal.reason}`, + ); + return; + } + setResult({ + status: "success", + blocknumber, + }); + }) + .catch((reason) => { + if (abortController.signal.aborted) { + console.info( + `Skipping promise catch handling: ${abortController.signal.reason}`, + ); + } + setResult({ + status: "error", + error: reason, + }); + }); + + return () => { + abortController.abort("Url or Chain changed."); + setResult({ status: "idle" }); + }; + }, [url, chain]); + + return result; +}; + +const handleSubmitFormErrors = (errors: FormErrors) => { + toPairs(errors).forEach(([key, value]) => { + notifications.show({ + id: key, + message: value, + color: "red", + }); + }); +}; + +const ConnectionForm: FC = ({ onConnectionSaved }) => { + const { addConnection } = useNodeConnection(); + const theme = useMantineTheme(); + const btnRef = useRef(null); + const successColor = useRightColorShade("green"); + const errorColor = useRightColorShade("red"); + const idleColor = useRightColorShade("gray"); + const form = useForm({ + validateInputOnChange: true, + initialValues: { + name: "", + url: "", + isPreferred: false, + chainRpcUrl: "", + }, + validate: { + name: (v) => { + if (isEmpty(v)) return `Name is a required field!`; + + return null; + }, + url: (v) => { + if (isEmpty(v)) return "URL is a required field!"; + }, + chainRpcUrl: (v) => { + if (isEmpty(v)) return "Chain json-rpc endpoint can't be blank"; + }, + }, + }); + const [submitting, setSubmitting] = useState(false); + const { url, chainRpcUrl } = form.getTransformedValues(); + const [debouncedUrl] = useDebouncedValue(url, 500); + const [debouncedChainUrl] = useDebouncedValue(chainRpcUrl, 500); + + const { validUrl } = React.useMemo( + () => checkURL(debouncedUrl), + [debouncedUrl], + ); + + const [nodeInfoResult] = useGetNodeInformation( + validUrl ? debouncedUrl : null, + ); + + const { chainId, nodeVersion } = + nodeInfoResult.status === "success" ? nodeInfoResult.data : {}; + + const chainCheckRes = isNotNil(chainId) ? checkChainId(chainId) : null; + const versionCheckRes = isNotNil(nodeVersion) + ? checkNodeVersion(nodeVersion) + : null; + + const nodeChain = + chainCheckRes?.status === "supported" ? chainCheckRes.chain : null; + + const chainRpcHealthCheck = useChainRpcHealthCheck( + debouncedChainUrl, + nodeChain, + ); + + const testSuccess = validUrl && nodeInfoResult.status === "success"; + const nodeInfoError = + nodeInfoResult.status === "error" ? nodeInfoResult.error : null; + + const canSave = + nodeInfoResult.status === "success" && + versionCheckRes?.status === "supported" && + chainCheckRes?.status === "supported" && + chainRpcHealthCheck.status === "success"; + + const showNodeInformation = + nodeInfoResult.status === "success" && + chainCheckRes !== null && + versionCheckRes !== null; + + const onSuccess = () => { + const { name } = form.getTransformedValues(); + notifications.show({ + message: `Connection ${name} created with success`, + color: "green", + withBorder: true, + }); + form.reset(); + setSubmitting(false); + onConnectionSaved?.(); + }; + + const onFailure = () => { + notifications.show({ + message: `Failed to create connection.`, + color: "red", + withBorder: true, + }); + }; + + useEffect(() => { + if (showNodeInformation) { + btnRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [showNodeInformation]); + + useEffect(() => { + if (nodeChain !== null) { + form.setFieldValue( + "chainRpcUrl", + nodeChain.rpcUrls.default.http[0], + ); + } else { + form.resetField("chainRpcUrl"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeChain]); + + return ( +
{ + if (canSave) { + setSubmitting(true); + + const newConfig: NodeConnectionConfig = { + name: values.name, + isPreferred: values.isPreferred, + url: values.url, + isDeletable: true, + timestamp: Date.now(), + type: "user", + version: nodeInfoResult.data.nodeVersion, + chain: { + id: nodeInfoResult.data.chainId, + rpcUrl: values.chainRpcUrl, + }, + }; + + addConnection(newConfig, { + onSuccess, + onFailure, + }); + } else { + notifications.show({ + message: + "To save a connection the endpoints must be working", + color: "orange", + withBorder: true, + }); + } + }, handleSubmitFormErrors)} + > + + + + ) : !validUrl || !url ? ( + + ) : validUrl && !testSuccess ? ( + + ) : ( + + ) + } + {...form.getInputProps("url")} + error={form.errors["url"] || nodeInfoError?.message} + /> + + + + {showNodeInformation && ( + <> + + + + + + + Version + + {nodeInfoResult.data.nodeVersion} + + + ) : ( + + ) + } + > + supported + + + + + + {versionCheckRes.status !== "supported" && ( + <> + + + {versionCheckRes.error.message} + + + {versionCheckRes.status === + "not-supported-version" && ( + + + Supported range:{" "} + { + versionCheckRes.extra + .supportedRange + } + + + )} + + )} + + + + + + + Chain + + {nodeInfoResult.data.chainId} + {chainCheckRes.status === + "supported" && + `(${chainCheckRes.chain.name})`} + + + ) : ( + + ) + } + > + supported + + + + + + {chainCheckRes.status === + "not-supported-chain" && ( + + {chainCheckRes.error.message} + + )} + + + + + + ) : chainRpcHealthCheck.status === + "idle" ? ( + + ) : chainRpcHealthCheck.status === + "error" ? ( + + ) : ( + + ) + } + {...form.getInputProps("chainRpcUrl")} + error={ + form.errors["chainRpcUrl"] || + (chainRpcHealthCheck.status === "error" && + path( + ["shortMessage"], + chainRpcHealthCheck.error, + )) + } + /> + + + + )} + + + + + + ); +}; + +export default ConnectionForm; diff --git a/apps/dave/src/components/connection/ConnectionModal.tsx b/apps/dave/src/components/connection/ConnectionModal.tsx new file mode 100644 index 000000000..b688669e7 --- /dev/null +++ b/apps/dave/src/components/connection/ConnectionModal.tsx @@ -0,0 +1,116 @@ +"use client"; +import { + Modal, + ScrollArea, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import { isEmpty, isNotNil } from "ramda"; +import { Activity, useEffect, useRef, type FC } from "react"; +import CenteredText from "../CenteredText"; +import ConnectionForm from "./ConnectionForm"; +import ConnectionView from "./ConnectionView"; +import { + useConnectionModalMode, + useNodeConnection, + useShowConnectionModal, +} from "./hooks"; + +export type ViewControl = "manage" | "create"; + +const connectionListMaxHeight = 380; + +const ConnectionModal: FC = () => { + const showModal = useShowConnectionModal(); + const viewMode = useConnectionModalMode(); + const { + closeConnectionModal, + listConnections, + getSelectedConnection, + changeViewMode, + } = useNodeConnection(); + const selectedConnection = getSelectedConnection(); + const viewport = useRef(null); + const connections = listConnections(); + const hasConnections = !isEmpty(connections); + + useEffect(() => { + viewport.current?.scrollTo({ + top: 0, + behavior: "smooth", + }); + }, [selectedConnection?.timestamp]); + + return ( + + node connections + + } + > + changeViewMode(value as ViewControl)} + data={[ + { value: "manage", label: "Manage" }, + { value: "create", label: "Create" }, + ]} + /> + + + + + + {isNotNil(selectedConnection) && ( + + )} + {connections.map((connection) => ( + + ))} + {!hasConnections && ( + + )} + + + + + + + {viewMode === "create" && ( + changeViewMode("manage")} + /> + )} + + + ); +}; + +export default ConnectionModal; diff --git a/apps/dave/src/components/connection/ConnectionProvider.tsx b/apps/dave/src/components/connection/ConnectionProvider.tsx new file mode 100644 index 000000000..fe1eef010 --- /dev/null +++ b/apps/dave/src/components/connection/ConnectionProvider.tsx @@ -0,0 +1,201 @@ +"use client"; +import { Button, Group, Stack, Text } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { useRouter } from "next/navigation"; +import { isEmpty, isNotNil } from "ramda"; +import { useEffect, useReducer, useRef, type FC, type ReactNode } from "react"; +import { pathBuilder } from "../../routes/routePathBuilder"; +import { + ConnectionActionContext, + ConnectionStateContext, + initState, + type ConnectionState, +} from "./ConnectionContexts"; +import ConnectionModal, { type ViewControl } from "./ConnectionModal"; +import { useCheckNodeConnection } from "./hooks"; +import IndexedDbRepository from "./indexedDbRepository"; +import reducer from "./reducer"; +import type { DbNodeConnectionConfig, Repository } from "./types"; + +const indexedDbRepository = new IndexedDbRepository(); + +interface ConnectionProviderProps { + children: ReactNode; + systemConnection: DbNodeConnectionConfig | null; + repository?: Repository; +} + +const getPreferredNodeConnection = ( + userConnections: DbNodeConnectionConfig[], + systemConnection: DbNodeConnectionConfig | null, +) => { + const conn = userConnections.find((config) => config.isPreferred); + return conn ?? systemConnection; +}; + +const getSelectedConfig = ( + state: ConnectionState, +): DbNodeConnectionConfig | null => { + return state.connections[state.selectedConnection ?? -1] ?? null; +}; + +const ConnectivityProblem: FC<{ message: string; onClick: () => void }> = ({ + message, + onClick, +}) => { + return ( + + + {message} + + + + + + ); +}; + +export const ConnectionProvider: FC = ({ + children, + systemConnection, + repository = indexedDbRepository, +}) => { + const [state, dispatch] = useReducer(reducer, repository, initState); + + const router = useRouter(); + const prev = useRef(state.selectedConnection); + const selectedConfig = getSelectedConfig(state); + const [result] = useCheckNodeConnection(selectedConfig); + + useEffect(() => { + dispatch({ + type: "set_fetching", + payload: true, + }); + repository + .list() + .then((userConnections) => { + const preferredConnection = getPreferredNodeConnection( + userConnections, + systemConnection, + ); + + if (null !== systemConnection) { + dispatch({ + type: "set_system_connection", + payload: { connection: systemConnection }, + }); + } + + dispatch({ + type: "set_connections", + payload: { connections: userConnections }, + }); + + if (null !== preferredConnection) { + dispatch({ + type: "set_selected_connection", + payload: { id: preferredConnection.id! }, + }); + } + }) + .catch((err) => + console.error( + `Error trying to fetch connections: ${err.message}`, + ), + ) + .finally(() => + dispatch({ + type: "set_fetching", + payload: false, + }), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repository]); + + useEffect(() => { + // when the selected-connection change we route the user back to home page. + if (prev.current !== state.selectedConnection) { + prev.current = state.selectedConnection; + router.push(pathBuilder.base, { scroll: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.selectedConnection]); + + useEffect(() => { + if (null !== prev.current && result.status === "error") { + const notificationId = prev.current.toString(); + notifications.show({ + id: notificationId, + color: "red", + autoClose: false, + title: "Connectivity problem!", + message: ( + { + dispatch({ type: "open_modal" }); + notifications.hide(notificationId); + }} + /> + ), + }); + } + }, [result]); + + useEffect(() => { + // the state starts with fetching. After startup if a connection is not selected; + // we display the connection modal for manage or creation depending on the situation + // The rest of the react tree must not be rendered until a connection is selected. + if (!state.fetching && state.selectedConnection === null) { + const hasConnections = + !isEmpty(state.connections) || isNotNil(state.systemConnection); + const config = hasConnections + ? { + action: "manage", + message: + "Select a connection. Mark as preferred to skip this step.", + title: "Connections", + } + : { + message: "Create your first connection.", + title: "Connection is required!", + action: "create", + }; + + notifications.show({ + id: config.action, + color: "blue", + message: config.message, + title: config.title, + }); + + dispatch({ + type: "open_modal", + payload: config.action as ViewControl, + }); + } + }, [ + state.fetching, + state.selectedConnection, + state.connections, + state.systemConnection, + ]); + + if (state.fetching) { + return ""; + } + + return ( + + + + {!selectedConfig ? "" : children} + + + ); +}; diff --git a/apps/dave/src/components/connection/ConnectionView.tsx b/apps/dave/src/components/connection/ConnectionView.tsx new file mode 100644 index 000000000..5b00c411f --- /dev/null +++ b/apps/dave/src/components/connection/ConnectionView.tsx @@ -0,0 +1,272 @@ +import { + ActionIcon, + Badge, + Button, + Card, + Group, + Stack, + Switch, + Text, + Tooltip, + useMantineTheme, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { pathOr } from "ramda"; +import { isNilOrEmpty } from "ramda-adjunct"; +import { Activity, type FC } from "react"; +import { TbRefresh } from "react-icons/tb"; +import { isDevnet } from "../../lib/supportedChains"; +import CopyButton from "../CopyButton"; +import { useGetNodeInformation, useNodeConnection } from "./hooks"; +import type { ConnectionNetworkStatus, DbNodeConnectionConfig } from "./types"; + +interface ConnectionViewProps { + connection: DbNodeConnectionConfig; + hideIfSelected?: boolean; + onConnect?: () => void; +} + +const colors = { + success: "green", + warn: "yellow", + info: "blue", + error: "red", +} as const; + +type NotificationType = "success" | "error" | "info" | "warn"; +type NotifyOpts = { + autoClose: Parameters[0]["autoClose"]; +}; + +const notify = ( + type: NotificationType, + message: string, + title?: string, + opts?: NotifyOpts, +) => notifications.show({ message, title, color: colors[type], ...opts }); + +type ConnectionStatusProps = { + status: ConnectionNetworkStatus; + errorMessage?: string; +}; +const ConnectionStatus: FC = ({ + status, + errorMessage, +}) => { + const config = + status === "pending" + ? { color: "orange", text: "checking..." } + : status === "error" + ? { color: "red", text: "not healthy" } + : status === "idle" + ? { color: "gray", text: "..." } + : { color: "green", text: "healthy" }; + + return ( + + + {config.text} + + + ); +}; + +const ConnectionView: FC = ({ + connection, + onConnect, + hideIfSelected = false, +}) => { + const { + removeConnection, + setSelectedConnection, + getSelectedConnection, + updateIsPreferred, + countConnectionSameChainDiffRpc, + } = useNodeConnection(); + const theme = useMantineTheme(); + const selectedConnection = getSelectedConnection(); + const isMock = connection.type === "system_mock"; + const [result, refetchNodeInfo] = useGetNodeInformation( + isMock ? null : connection.url, + ); + + const isSelected = selectedConnection?.id === connection.id; + const isSystem = ["system", "system_mock"].includes(connection.type); + const hideFooter = isSystem && isSelected; + + if (isSelected && hideIfSelected) return ""; + + return ( + + + + {connection?.name} + {isSelected && selected} + + + + + + status: + + + + + + + + + url: + {connection.url} + + + + + node version: + {connection.version} + + + + Chain id: + {connection.chain.id} + + + + Chain RPC: + {connection.chain.rpcUrl} + + + + + + + + { + updateIsPreferred( + { + newValue: evt.currentTarget.checked, + connection, + }, + { + onFailure: (reason) => + notify( + "error", + pathOr( + "Could not update the connection", + ["message"], + reason, + ), + ), + }, + ); + }} + /> + + + + + {connection.isDeletable && ( + + )} + + + + + + + + + ); +}; + +export default ConnectionView; diff --git a/apps/dave/src/components/connection/functions.ts b/apps/dave/src/components/connection/functions.ts new file mode 100644 index 000000000..e93cbd63c --- /dev/null +++ b/apps/dave/src/components/connection/functions.ts @@ -0,0 +1,78 @@ +import { createCartesiPublicClient } from "@cartesi/viem"; +import { descend, isNil, prop, sort } from "ramda"; +import { http } from "viem"; +import type { DbNodeConnectionConfig } from "./types"; + +export const sortByTimestampDesc = sort( + descend(prop("timestamp")), +); + +type NodeMetaResult = + | { + status: "error"; + error: Error; + } + | { + status: "success"; + nodeVersion: string; + chainId: number; + }; + +/** + * + * Request both rollups node's version and chain-id. + * the returned object has status and adjacents properties based on it. + * + * @param cartesiNodeUrl + */ +export const fetchRollupsNodeMeta = async ( + cartesiNodeUrl: string, +): Promise => { + const cartesiClient = createCartesiPublicClient({ + transport: http(cartesiNodeUrl, { timeout: 5000 }), + }); + + const promises: [chainId: Promise, nodeVersion: Promise] = [ + cartesiClient.getChainId(), + cartesiClient.getNodeVersion(), + ]; + + try { + const [chainIdSettled, nodeVersionSettled] = + await Promise.allSettled(promises); + const bothFailed = + chainIdSettled.status === "rejected" && + nodeVersionSettled.status === "rejected"; + if (bothFailed) { + return { + status: "error", + error: new Error("Looks like the node is not responding."), + }; + } + + const nodeVersion = + nodeVersionSettled.status === "fulfilled" + ? nodeVersionSettled.value + : null; + const chainId = + chainIdSettled.status === "fulfilled" ? chainIdSettled.value : null; + const errorMessages: string[] = []; + + if (isNil(nodeVersion)) + errorMessages.push("does not provide a node-version."); + + if (isNil(chainId)) errorMessages.push("does not provide a chain-id."); + + if (isNil(nodeVersion) || isNil(chainId)) + return { + status: "error", + error: new Error( + `${cartesiNodeUrl} ${errorMessages.join(" and ")}`, + ), + }; + + return { status: "success", nodeVersion, chainId }; + } catch (error) { + return { status: "error", error: error as Error }; + } +}; diff --git a/apps/dave/src/components/connection/hooks.tsx b/apps/dave/src/components/connection/hooks.tsx new file mode 100644 index 000000000..b6b28e5e2 --- /dev/null +++ b/apps/dave/src/components/connection/hooks.tsx @@ -0,0 +1,484 @@ +"use client"; +import { path, pathOr } from "ramda"; +import { isNotNilOrEmpty } from "ramda-adjunct"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { supportedChains } from "../../lib/supportedChains"; +import { useAppConfig } from "../../providers/AppConfigProvider"; +import { + ConnectionActionContext, + ConnectionStateContext, +} from "./ConnectionContexts"; +import type { ViewControl } from "./ConnectionModal"; +import { fetchRollupsNodeMeta, sortByTimestampDesc } from "./functions"; +import type IndexedDbRepository from "./indexedDbRepository"; +import type { + ConnectionNetworkStatus, + DbNodeConnectionConfig, + NodeConnectionConfig, +} from "./types"; + +export type NodeInformationResult = + | { + status: "success"; + data: { chainId: number; nodeVersion: string }; + } + | { + status: "error"; + error: Error; + } + | { + status: Exclude; + }; + +export type GetNodeInformationResult = [ + result: NodeInformationResult, + retry: () => void, +]; + +type ActionLifecycle = { + onSuccess?: (...params: T extends undefined ? [never?] : [T]) => void; + onFailure?: (reason?: unknown) => void; + onFinished?: () => void; +}; + +const useConnectionState = () => { + return useContext(ConnectionStateContext); +}; + +const useConnectionRepository = () => { + const state = useConnectionState(); + return useMemo(() => state.repository, [state.repository]); +}; + +/** + * Retrieve a dictionary of connections loaded, keys are the ids and value is the + * node-connection-config. + * @returns {Record} Connections dictionary + */ +const useConnectionsMap = () => { + const state = useConnectionState(); + return state.connections; +}; + +const useNodeConnectionActions = () => { + const repository = useConnectionRepository(); + const dispatch = useContext(ConnectionActionContext); + + return useMemo( + () => ({ + changeViewMode: (viewControl: ViewControl) => + dispatch({ type: "open_modal", payload: viewControl }), + openConnectionModal: () => dispatch({ type: "open_modal" }), + closeConnectionModal: () => dispatch({ type: "close_modal" }), + addConnection: ( + newConn: NodeConnectionConfig, + opt?: ActionLifecycle>, + ) => { + repository + .add(newConn) + .then((connection) => { + dispatch({ + type: "add_connection", + payload: { connection }, + }); + opt?.onSuccess?.(connection); + }) + .catch((reason) => { + console.error(reason); + opt?.onFailure?.(reason); + }) + .finally(() => opt?.onFinished?.()); + }, + removeConnection: (id: number, opt?: ActionLifecycle) => { + repository + .remove(id) + .then((isDeleted) => { + if (isDeleted) { + dispatch({ + type: "remove_connection", + payload: { id }, + }); + opt?.onSuccess?.(); + } else { + opt?.onFailure?.( + `Connection ${id} was not removed`, + ); + } + }) + .catch((reason) => { + console.error(reason); + opt?.onFailure?.(reason); + }); + }, + setSelectedConnection: (id: number) => + dispatch({ + type: "set_selected_connection", + payload: { id }, + }), + updateIsPreferred: async ( + params: { + newValue: boolean; + connection: DbNodeConnectionConfig; + }, + opt?: ActionLifecycle, + ) => { + // casting for raw access. + const db = repository as IndexedDbRepository; + try { + const modifyCount = await db.connections + .toCollection() + .modify({ isPreferred: false }); + console.info(`Modified ${modifyCount} entries`); + const updateResult = await db.connections.update( + params.connection.id, + { isPreferred: params.newValue }, + ); + console.info( + `Connection id ${params.connection.id} ${updateResult === 0 ? "was not" : "was"} updated!`, + ); + + if (updateResult === 1) { + const newList = await repository.list(); + dispatch({ + type: "set_connections", + payload: { connections: newList }, + }); + opt?.onSuccess?.(); + } else { + opt?.onFailure?.( + new Error("The connection could not be updated."), + ); + } + } catch (error) { + console.error(error); + opt?.onFailure?.( + pathOr( + "Something went wrong when trying to update the connection", + ["message"], + error, + ), + ); + } finally { + opt?.onFinished?.(); + } + }, + }), + [dispatch, repository], + ); +}; + +/** + * Retrieve the selected connection id. + * based on ConnectionProvider data information + */ +const useSelectedConnection = () => { + const state = useConnectionState(); + return state.selectedConnection; +}; + +// ###### EXPORTED HOOKS + +/** + * Retrieve the selected node connection instance to be used + * based on ConnectionProvider data information + * @returns + */ +export const useSelectedNodeConnection = (): DbNodeConnectionConfig | null => { + const connections = useConnectionsMap(); + const id = useSelectedConnection(); + return connections[id ?? -1] ?? null; +}; + +export const useNodeConnection = () => { + const connections = useConnectionsMap(); + const selectedConnection = useSelectedConnection(); + const actions = useNodeConnectionActions(); + + const functions = useMemo(() => { + const getConnection = (id: number) => connections[id]; + const listConnections = () => + sortByTimestampDesc(Object.values(connections)); + const getSelectedConnection = () => + getConnection(selectedConnection ?? -1); + const hasChainRegistered = (chainId: number) => + listConnections().some((conn) => conn.chain.id === chainId); + const countConnectionSameChainDiffRpc = ( + chainId: number, + rpcUrl: string, + ) => + listConnections().filter( + (conn) => + conn.chain.id === chainId && + conn.chain.rpcUrl.toLowerCase() !== rpcUrl.toLowerCase(), + ).length; + + return { + getConnection, + listConnections, + getSelectedConnection, + hasChainRegistered, + countConnectionSameChainDiffRpc, + }; + }, [connections, selectedConnection]); + + return { + getSelectedConnection: functions.getSelectedConnection, + getConnection: functions.getConnection, + hasChainRegistered: functions.hasChainRegistered, + listConnections: functions.listConnections, + countConnectionSameChainDiffRpc: + functions.countConnectionSameChainDiffRpc, + ...actions, + }; +}; + +/** + * This is meant to provide fresh cartesi-client + * avoiding any possible cartesi-provider that may or not be available. + * It will fetch the rollups-node chain-id and node-version of the + * url defined. If URL is nil a noop is returned. + * @param cartesiNodeUrl + */ +export const useGetNodeInformation = ( + cartesiNodeUrl: string | null, +): GetNodeInformationResult => { + const [toRetry, setToRetry] = useState(0); + const [result, setResult] = useState({ + status: "idle", + }); + + const retry = useCallback(() => { + setToRetry(Date.now()); + }, [setToRetry]); + + useEffect(() => { + if (!cartesiNodeUrl) { + setResult({ status: "noop" }); + return; + } + + setResult({ status: "pending" }); + + const abortController = new AbortController(); + + fetchRollupsNodeMeta(cartesiNodeUrl).then((result) => { + if (abortController.signal.aborted) return; + + if (result.status === "success") { + setResult({ + status: "success", + data: { + chainId: result.chainId, + nodeVersion: result.nodeVersion, + }, + }); + } else if (result.status === "error") { + setResult({ status: "error", error: result.error }); + } else { + console.warn(`Unhandled rollusp-node-meta result ${result}`); + } + }); + + return () => { + abortController.abort( + `The cartesi rollups node's url changed. Ignoring responses from ${cartesiNodeUrl}`, + ); + setResult({ status: "idle" }); + }; + }, [cartesiNodeUrl, toRetry]); + + return [result, retry]; +}; + +type NodeConnectionResult = { + status: ConnectionNetworkStatus; + matchVersion?: boolean; + matchChain?: boolean; + error?: Error; + response?: { + chainId: number; + nodeVersion: string; + }; +}; + +export type CheckNodeConnectionReturn = [ + result: NodeConnectionResult, + retry: () => void, +]; +/** + * Based on a node-connection-configuration + * Checks if the target node-rollups-rpc-api is still running, + * and, checks if the node-version and chain-id in the configuration + * matches with the response results. It is up to the caller + * to define what to do next. + * @param config + * @returns + */ +export const useCheckNodeConnection = ( + config?: DbNodeConnectionConfig | null, +): CheckNodeConnectionReturn => { + const [toRetry, setToRetry] = useState(0); + const [result, setResult] = useState({ + status: "idle", + }); + + const retry = useCallback(() => setToRetry(Date.now()), [setToRetry]); + + useEffect(() => { + if (!config?.url) { + setResult({ status: "noop" }); + return; + } + + if (config.type === "system_mock") { + setResult({ + status: "success", + matchChain: true, + matchVersion: true, + response: { + chainId: config.chain.id, + nodeVersion: config.version, + }, + }); + return; + } + + setResult({ status: "pending" }); + + const abortController = new AbortController(); + + fetchRollupsNodeMeta(config.url).then((result) => { + if (abortController.signal.aborted) return; + + if (result.status === "success") { + const { chainId, nodeVersion } = result; + const matchChain = + config.chain?.toString() === chainId.toString(); + const matchVersion = config.version === nodeVersion.toString(); + + setResult({ + status: "success", + matchChain, + matchVersion, + response: { + chainId, + nodeVersion, + }, + }); + } else if (result.status === "error") { + setResult({ status: "error", error: result.error }); + } else { + console.warn(`Unhandled rollusp-node-meta result ${result}`); + } + }); + + return () => { + abortController.abort( + `The node connection configuration changed. Ignoring responses from ${config.url}`, + ); + setResult({ status: "idle" }); + }; + }, [config?.url, config?.chain, config?.version, config?.type, toRetry]); + + return [result, retry]; +}; + +export const useShowConnectionModal = () => { + const state = useConnectionState(); + return useMemo( + () => state.showConnectionModal, + [state.showConnectionModal], + ); +}; + +export const useConnectionModalMode = () => { + const state = useConnectionState(); + return state.connectionModalMode; +}; + +export const useSystemConnection = () => { + const state = useConnectionState(); + return useMemo(() => state.systemConnection, [state.systemConnection]); +}; + +const defaultVal = { + id: Number.MAX_SAFE_INTEGER, + chain: { + id: 13370, + rpcUrl: pathOr( + "http://localhost:8545", + ["13370", "rpcUrls", "default", "http", "0"], + supportedChains, + ), + }, + isDeletable: false, + isPreferred: true, + timestamp: Date.now(), + version: "2.0.0-alpha.9", +}; + +type BuildSystemNodeReturn = { + config: DbNodeConnectionConfig | null; + isFetching?: boolean; +}; + +/** + * @description For internal use. It builds a system-node-connection based on parameters + * For internal use + * @param cartesiNodeRpcUrl + * @param isMockEnabled + * @returns + */ +export const useBuildSystemNodeConnection = ( + cartesiNodeRpcUrl: string, + isMockEnabled: boolean, +): BuildSystemNodeReturn => { + const url = isMockEnabled ? null : cartesiNodeRpcUrl; + const [result] = useGetNodeInformation(url); + const appConfig = useAppConfig(); + + if (isMockEnabled) { + return { + config: { + ...defaultVal, + name: "mocked-system-setup", + type: "system_mock", + url: "local://in-memory", + }, + }; + } + + if (result.status === "pending") { + return { config: null, isFetching: true }; + } + + if (isNotNilOrEmpty(cartesiNodeRpcUrl) && result.status === "success") { + const rpcUrl = + appConfig.nodeRpcUrl ?? + path( + [ + result.data.chainId.toString(), + "rpcUrls", + "default", + "http", + "0", + ], + supportedChains, + ); + return { + config: { + ...defaultVal, + name: "system-set-node-rpc", + type: "system", + url: cartesiNodeRpcUrl, + chain: { + id: result.data.chainId, + rpcUrl: rpcUrl, + }, + version: result.data.nodeVersion, + }, + }; + } + + return { config: null }; +}; diff --git a/apps/dave/src/components/connection/indexedDbRepository.ts b/apps/dave/src/components/connection/indexedDbRepository.ts new file mode 100644 index 000000000..1d5ca0091 --- /dev/null +++ b/apps/dave/src/components/connection/indexedDbRepository.ts @@ -0,0 +1,54 @@ +"use client"; +import Dexie, { type Table } from "dexie"; +import { databaseName } from "../../lib/db"; +import { + type DbNodeConnectionConfig, + type NodeConnectionConfig, + type Repository, +} from "./types"; + +export interface ConnectionItem extends NodeConnectionConfig { + network: string; +} + +/** + * Implements the Repository interface providing a persistent storage. + * It uses the IndexedDb underneath. + */ +class IndexedDbRepository extends Dexie implements Repository { + // Notify the typing system that 'connections' is added by dexie when declaring the stores() + connections!: Table; + + constructor() { + super(databaseName); + // Create first version of the connections store with auto-incremented id as primary key + // Don't declare all columns like in SQL. You only declare properties you want to index for where() clause use. + this.version(1).stores({ + connections: "++id, name, chain, url, version, type", + }); + } + + async add(newConn: NodeConnectionConfig) { + const id = await this.connections.add(newConn); + return { ...newConn, id } as DbNodeConnectionConfig; + } + + async remove(id: number) { + const deleteCount = await this.connections.where({ id }).delete(); + + return deleteCount > 0; + } + + async get(id: number) { + const connection = (await this.connections.get( + id, + )) as DbNodeConnectionConfig; + return connection ?? null; + } + + async list() { + return (await this.connections.toArray()) as DbNodeConnectionConfig[]; + } +} + +export default IndexedDbRepository; diff --git a/apps/dave/src/components/connection/reducer.ts b/apps/dave/src/components/connection/reducer.ts new file mode 100644 index 000000000..6343af9b9 --- /dev/null +++ b/apps/dave/src/components/connection/reducer.ts @@ -0,0 +1,86 @@ +"use client"; +import { isNil, omit, pathOr } from "ramda"; +import type { ConnectionReducer, ConnectionState } from "./ConnectionContexts"; +import type { DbNodeConnectionConfig } from "./types"; + +const mapById = ( + accumulator: ConnectionState["connections"], + next: DbNodeConnectionConfig, +) => { + if (next.id) { + accumulator[next.id] = next; + } + return accumulator; +}; + +const connectionsById = ( + conns: DbNodeConnectionConfig[], + systemConnection: DbNodeConnectionConfig | null, +) => { + const newList = isNil(systemConnection) + ? conns + : [...conns, systemConnection]; + return newList.reduce(mapById, {}); +}; + +const reducer: ConnectionReducer = (state, action) => { + switch (action.type) { + case "open_modal": + return { + ...state, + showConnectionModal: true, + connectionModalMode: pathOr("manage", ["payload"], action), + }; + case "close_modal": + return { + ...state, + showConnectionModal: false, + }; + + case "add_connection": + return { + ...state, + connections: { + ...state.connections, + [action.payload.connection.id]: action.payload.connection, + }, + }; + case "remove_connection": + return { + ...state, + connections: omit([action.payload.id], state.connections), + }; + case "set_connections": + return { + ...state, + connections: connectionsById( + action.payload.connections, + state.systemConnection, + ), + }; + case "set_fetching": + return { + ...state, + fetching: action.payload, + }; + case "set_selected_connection": { + return { + ...state, + selectedConnection: action.payload.id, + }; + } + case "set_system_connection": + return { + ...state, + systemConnection: action.payload.connection, + connections: { + ...state.connections, + [action.payload.connection.id]: action.payload.connection, + }, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/apps/dave/src/components/connection/types.ts b/apps/dave/src/components/connection/types.ts new file mode 100644 index 000000000..c764b46c7 --- /dev/null +++ b/apps/dave/src/components/connection/types.ts @@ -0,0 +1,33 @@ +export type ConnectionNetworkStatus = + | "idle" + | "pending" + | "error" + | "success" + | "noop"; + +type ConfigType = "system" | "system_mock" | "user"; + +export interface NodeConnectionConfig { + // saved instances has an ID generated by the database. + id?: number; + name: string; + url: string; + chain?: { + id: number; + rpcUrl: string; + }; + version: string; + timestamp: number; + isPreferred: boolean; + isDeletable: boolean; + type: ConfigType; +} + +export type DbNodeConnectionConfig = Required; + +export interface Repository { + add: (conn: NodeConnectionConfig) => Promise; + remove: (id: number) => Promise; + get: (id: number) => Promise; + list: () => Promise; +} diff --git a/apps/dave/src/components/epoch/EpochCard.tsx b/apps/dave/src/components/epoch/EpochCard.tsx index fc8f50f30..b8eab2a0f 100644 --- a/apps/dave/src/components/epoch/EpochCard.tsx +++ b/apps/dave/src/components/epoch/EpochCard.tsx @@ -2,15 +2,20 @@ import type { Epoch } from "@cartesi/viem"; import { Badge, Card, Group, Text, useMantineTheme } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; import Link from "next/link"; +import { useParams } from "next/navigation"; import type { FC } from "react"; +import { pathBuilder } from "../../routes/routePathBuilder"; import { useEpochStatusColor } from "./useEpochStatusColor"; type Props = { epoch: Epoch }; export const EpochCard: FC = ({ epoch }) => { const theme = useMantineTheme(); - const epochIndex = epoch.index?.toString() ?? "0"; - const url = `epochs/${epochIndex}`; + const params = useParams<{ application: string }>(); + const url = pathBuilder.epoch({ + application: params.application, + epochIndex: epoch.index, + }); const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.sm})`); const color = useEpochStatusColor(epoch); const inDispute = false; // XXX: how to know if an epoch is in dispute? diff --git a/apps/dave/src/components/input/InputCard.tsx b/apps/dave/src/components/input/InputCard.tsx index a64a2eddf..ae2b776f2 100644 --- a/apps/dave/src/components/input/InputCard.tsx +++ b/apps/dave/src/components/input/InputCard.tsx @@ -10,17 +10,21 @@ import { Stack, Text, Tooltip, + useMantineTheme, type MantineColor, } from "@mantine/core"; +import { isNotNil } from "ramda"; import { Activity, useMemo, useState, type FC } from "react"; import { TbReceipt } from "react-icons/tb"; import useRightColorShade from "../../hooks/useRightColorShade"; import { getDecoder } from "../../lib/decoders"; import Address from "../Address"; +import JSONViewer from "../JSONViewer"; import { PrettyTime } from "../PrettyTime"; import TransactionHash from "../TransactionHash"; import { OutputContainer } from "../output/OutputContainer"; import { ReportContainer } from "../report/ReportContainer"; +import { useAbiDecodingOnInput } from "../specification/hooks/useAbiDecodingOnInput"; import { contentDisplayOptions, type DecoderType } from "../types"; interface Props { @@ -41,20 +45,32 @@ const getStatusColor = (status: InputStatus): MantineColor => { type ViewControl = "payload" | "output" | "report"; const maxHeight = 450; -const iconSize = 21; // TODO: Define what else will be inside like payload (decoding etc) export const InputCard: FC = ({ input }) => { + const theme = useMantineTheme(); const statusColor = useRightColorShade(getStatusColor(input.status)); const [viewControl, setViewControl] = useState("payload"); const [decoderType, setDecoderType] = useState("raw"); const decoderFn = useMemo(() => getDecoder(decoderType), [decoderType]); const millis = Number(input.decodedData.blockTimestamp * 1000n); + const [result, decodingInfo] = useAbiDecodingOnInput(input); + const hasDecodedContent = isNotNil(decodingInfo.specApplied); + const isDecodedSelected = decoderType === "decoded"; + const inputContent = + hasDecodedContent && isDecodedSelected + ? decoderFn(result) + : decoderFn(input.decodedData.payload); return ( -
+
# {input.index} = ({ input }) => { />