Skip to content

Commit

Permalink
feat: allow node selection after connection error
Browse files Browse the repository at this point in the history
  • Loading branch information
samsiegart committed Jan 3, 2024
1 parent b01aa28 commit bb477dd
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 9 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@agoric/cosmic-proto": "^0.3.0",
"@agoric/ertp": "^0.16.2",
"@agoric/casting": "^0.4.3-u13.0",
"@agoric/inter-protocol": "^0.16.1",
"@agoric/rpc": "^0.9.0",
"@agoric/smart-wallet": "^0.5.3",
Expand Down
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
networkConfigAtom,
rpcNodeAtom,
appStore,
savedApiNodeAtom,
savedRpcNodeAtom,
} from 'store/app';
import Root from 'views/Root';
import DisclaimerDialog from 'components/DisclaimerDialog';
Expand All @@ -27,6 +29,7 @@ import 'styles/globals.css';
import ProvisionSmartWalletDialog from 'components/ProvisionSmartWalletDialog';
import ChainConnectionErrorDialog from 'components/ChainConnectionErrorDialog';
import { useStore } from 'zustand';
import NodeSelectorDialog from 'components/NodeSelectorDialog';

const router = createHashRouter([
{
Expand Down Expand Up @@ -84,6 +87,8 @@ const useAppVersionWatcher = () => {

const App = () => {
const netConfig = useAtomValue(networkConfigAtom);
const savedRpcNode = useAtomValue(savedRpcNodeAtom);
const savedApiNode = useAtomValue(savedApiNodeAtom);
const [chainStorageWatcher, setChainStorageWatcher] = useAtom(
chainStorageWatcherAtom,
);
Expand All @@ -97,9 +102,9 @@ const App = () => {
try {
const { rpc, chainName, api } = await fetchChainInfo(netConfig.url);
if (isCancelled) return;
setRpcNode(rpc);
setRpcNode(savedRpcNode || rpc);
setChainStorageWatcher(
makeAgoricChainStorageWatcher(api, chainName, e => {
makeAgoricChainStorageWatcher(savedApiNode || api, chainName, e => {
console.error(e);
setChainConnectionError(
new Error(
Expand Down Expand Up @@ -131,6 +136,8 @@ const App = () => {
setChainStorageWatcher,
setRpcNode,
setChainConnectionError,
savedRpcNode,
savedApiNode,
]);

useTimeKeeper();
Expand All @@ -152,6 +159,7 @@ const App = () => {
<AppVersionDialog />
<ProvisionSmartWalletDialog />
<ChainConnectionErrorDialog />
<NodeSelectorDialog />
</div>
);
};
Expand Down
39 changes: 34 additions & 5 deletions src/components/ChainConnectionErrorDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { useEffect, useState } from 'react';
import { appStore, chainStorageWatcherAtom, rpcNodeAtom } from 'store/app';
import { useAtomValue } from 'jotai';
import {
appStore,
chainStorageWatcherAtom,
rpcNodeAtom,
isNodeSelectorOpenAtom,
} from 'store/app';
import { useAtomValue, useSetAtom } from 'jotai';
import ActionsDialog from './ActionsDialog';
import { useStore } from 'zustand';

const ChainConnectionErrorDialog = () => {
const { chainConnectionError } = useStore(appStore);
const chainStorageWatcher = useAtomValue(chainStorageWatcherAtom);
const setIsNodeSelectorOpen = useSetAtom(isNodeSelectorOpenAtom);
const rpcNode = useAtomValue(rpcNodeAtom);
const [isShowing, setIsShowing] = useState(false);

Expand All @@ -26,16 +32,31 @@ const ChainConnectionErrorDialog = () => {
{chainStorageWatcher && (
<p>
API Endpoint:{' '}
<span className="text-blue-500">{chainStorageWatcher?.apiAddr}</span>
<a
target="_blank"
href={chainStorageWatcher?.apiAddr}
className="text-blue-500"
rel="noreferrer"
>
{chainStorageWatcher?.apiAddr}
</a>
</p>
)}
{rpcNode && (
<p>
RPC Endpoint: <span className="text-blue-500">{rpcNode}</span>
RPC Endpoint:{' '}
<a
target="_blank"
href={rpcNode}
className="text-blue-500"
rel="noreferrer"
>
{rpcNode}
</a>
</p>
)}
<p>
Error:{' '}
Details:{' '}
<span className="text-alert">{chainConnectionError?.toString()}</span>
</p>
</div>
Expand All @@ -46,6 +67,13 @@ const ChainConnectionErrorDialog = () => {
title="Chain Connection Error"
body={body}
isOpen={isShowing}
primaryAction={{
action: () => {
setIsShowing(false);
setIsNodeSelectorOpen(true);
},
label: 'Connection Settings',
}}
secondaryAction={{
action: () => {
setIsShowing(false);
Expand All @@ -55,6 +83,7 @@ const ChainConnectionErrorDialog = () => {
onClose={() => {
setIsShowing(false);
}}
initialFocusPrimary
/>
);
};
Expand Down
56 changes: 56 additions & 0 deletions src/components/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Combobox as HeadlessComboBox, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { FiChevronDown } from 'react-icons/fi';

const Combobox = ({
options,
value,
onValueChange,
}: {
options?: string[];
value?: string;
onValueChange: (newValue: string) => void;
}) => {
return (
<HeadlessComboBox value={value ?? ''} onChange={onValueChange}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden rounded-lg border-solid border-[#d8d8d8] border-[1px] text-left">
<HeadlessComboBox.Input
className="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0 focus-visible:outline-none"
onChange={event => onValueChange(event.target.value)}
/>
{options && (
<HeadlessComboBox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<FiChevronDown
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</HeadlessComboBox.Button>
)}
</div>
{options && (
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<HeadlessComboBox.Options className="z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
{options?.map(option => (
<HeadlessComboBox.Option
key={option}
value={option}
className="relative cursor-pointer select-none py-2 pl-4 pr-4 text-gray-900 hover:bg-gray-100"
>
{option}
</HeadlessComboBox.Option>
))}
</HeadlessComboBox.Options>
</Transition>
)}
</div>
</HeadlessComboBox>
);
};

export default Combobox;
12 changes: 10 additions & 2 deletions src/components/NetworkDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Fragment, MouseEventHandler } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { useAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { FiChevronDown } from 'react-icons/fi';
import { networkConfigAtom } from 'store/app';
import {
networkConfigAtom,
savedApiNodeAtom,
savedRpcNodeAtom,
} from 'store/app';
import { networkConfigs } from 'config';

const Item = ({
Expand Down Expand Up @@ -45,12 +49,16 @@ const NetworkDropdown = () => {
console.error('invalid network requested', specifiedNetworkName);
}
}
const setSavedRpc = useSetAtom(savedRpcNodeAtom);
const setSavedApi = useSetAtom(savedApiNodeAtom);

const items = Object.values(networkConfigs).map(config => (
<Item
key={config.url}
onClick={() => {
setNetworkConfig(config);
setSavedApi(null);
setSavedRpc(null);
window.location.reload();
}}
label={config.label}
Expand Down
115 changes: 115 additions & 0 deletions src/components/NodeSelectorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
chainStorageWatcherAtom,
isNodeSelectorOpenAtom,
networkConfigAtom,
rpcNodeAtom,
savedApiNodeAtom,
savedRpcNodeAtom,
} from 'store/app';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import ActionsDialog from './ActionsDialog';
import Combobox from 'components/Combobox';
import { useEffect, useRef, useState } from 'react';

Check warning on line 12 in src/components/NodeSelectorDialog.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

'useRef' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 12 in src/components/NodeSelectorDialog.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

'useRef' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 12 in src/components/NodeSelectorDialog.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

'useRef' is defined but never used. Allowed unused vars must match /^_/u
import { fetchAllAddrs } from 'utils/rpc';

const useRpcAddrs = () => {
const [rpcAddrs, setRpcAddrs] = useState([]);
const [apiAddrs, setApiAddrs] = useState([]);
const networkConfig = useAtomValue(networkConfigAtom);

useEffect(() => {
const fetchAddrs = async () => {
const { rpcAddrs, apiAddrs } = await fetchAllAddrs(networkConfig.url);
setRpcAddrs(rpcAddrs);
setApiAddrs(apiAddrs);
};

try {
fetchAddrs();
} catch (e) {
console.error('Error loading RPC Addrs', e);
}
}, [networkConfig]);

return { rpcAddrs, apiAddrs };
};
const NodeSelectorDialog = () => {
const [isOpen, setIsOpen] = useAtom(isNodeSelectorOpenAtom);
const { rpcAddrs, apiAddrs } = useRpcAddrs();
const watcher = useAtomValue(chainStorageWatcherAtom);
const rpcAddr = useAtomValue(rpcNodeAtom);
const [api, setApi] = useState(watcher?.apiAddr);
const [rpc, setRpc] = useState(rpcAddr ?? undefined);
const [initialApi, setInitialApi] = useState(api);
const [initialRpc, setInitialRpc] = useState(rpc);
const setSavedRpc = useSetAtom(savedRpcNodeAtom);
const setSavedApi = useSetAtom(savedApiNodeAtom);

const save = () => {
assert(api && rpc);
setSavedApi(api);
setSavedRpc(rpc);
setIsOpen(false);
window.location.reload();
};

useEffect(() => {
if (isOpen) {
const currentRpc = rpcAddr ?? undefined;
const currentApi = watcher?.apiAddr;
setRpc(rpcAddr ?? undefined);
setApi(watcher?.apiAddr);
setInitialRpc(currentRpc);
setInitialApi(currentApi);
}
}, [isOpen, rpcAddr, watcher?.apiAddr]);

const body = (
<div className="mt-2 p-1 max-h-96">
<p>RPC Endpoint:</p>
<Combobox
onValueChange={(value: string) => {
setRpc(value);
}}
value={rpc}
options={rpcAddrs}
/>
<p className="mt-4">API Endpoint:</p>
<Combobox
onValueChange={(value: string) => {
setApi(value);
}}
value={api}
options={apiAddrs}
/>
</div>
);

return (
<ActionsDialog
title="Connection Settings"
body={body}
isOpen={isOpen}
primaryAction={{
action: save,
label: 'Save',
}}
secondaryAction={{
action: () => {
setIsOpen(false);
},
label: 'Cancel',
}}
onClose={() => {
setIsOpen(false);
}}
initialFocusPrimary
overflow
primaryActionDisabled={
!(api && rpc) || (initialApi === api && initialRpc === rpc)
}
/>
);
};

export default NodeSelectorDialog;
21 changes: 21 additions & 0 deletions src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export const offerIdsToPublicSubscribersAtom = atom(
get => get(appAtom).offerIdsToPublicSubscribers,
);

export const chainConnectionErrorAtom = atom(
get => get(appAtom).chainConnectionError,
(get, set, error: Error) => {
if (get(appAtom).chainConnectionError === null) {
set(appAtom, state => ({ ...state, chainConnectionError: error }));
}
},
);

export const isNodeSelectorOpenAtom = atom(false);

export type DisplayFunctions = ReturnType<typeof makeDisplayFunctions>;

export const displayFunctionsAtom = atom(get => {
Expand Down Expand Up @@ -177,3 +188,13 @@ export const provisionToastIdAtom = atom<ToastId | undefined>(undefined);
export const smartWalletProvisionedAtom = atom(
get => get(appAtom).smartWalletProvisioned,
);

export const savedRpcNodeAtom = atomWithStorage<string | null>(
'savedRpcNode',
null,
);

export const savedApiNodeAtom = atomWithStorage<string | null>(
'savedApiNode',
null,
);
12 changes: 12 additions & 0 deletions src/utils/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,15 @@ export const fetchChainInfo = async (netconfigURL: string) => {
chainName,
};
};

export const fetchAllAddrs = async (netconfigURL: string) => {
const response = await fetch(netconfigURL, {
headers: { accept: 'application/json' },
});
const { rpcAddrs, apiAddrs } = await response.json();

return {
rpcAddrs,
apiAddrs,
};
};

0 comments on commit bb477dd

Please sign in to comment.