Skip to content

Commit

Permalink
feat!: UX improvements regarding agent activity (#349)
Browse files Browse the repository at this point in the history
* feat: improvement on Agent head

* Refactor rewards section labels for consistency (work period to epoch)

* feat: add getTimeAgo util

* feat: add useAddress hook

* feat: update time util function

* feat: add LastTransaction component

* feat: Add pull request template

* feat: add getLatestTransaction util

* feat: update recursion

* feat: update Idle agent head color

* feat: Address Tanya & Josh comments
  • Loading branch information
mohandast52 authored Sep 13, 2024
1 parent 6814d41 commit 412449a
Show file tree
Hide file tree
Showing 13 changed files with 272 additions and 19 deletions.
10 changes: 10 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Proposed changes

## Types of changes

What types of changes does your code introduce?
_Put an `x` in the boxes that apply_

- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
39 changes: 34 additions & 5 deletions frontend/components/MainPage/header/AgentButton.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, ButtonProps, Flex, Popover, Typography } from 'antd';
import { Button, ButtonProps, Flex, Popover, Tooltip, Typography } from 'antd';
import { useCallback, useMemo } from 'react';

import { Chain, DeploymentStatus } from '@/client';
import { COLOR } from '@/constants/colors';
import { useBalance } from '@/hooks/useBalance';
import { useElectronApi } from '@/hooks/useElectronApi';
import { useReward } from '@/hooks/useReward';
import { useServices } from '@/hooks/useServices';
import { useServiceTemplates } from '@/hooks/useServiceTemplates';
import { useStakingContractInfo } from '@/hooks/useStakingContractInfo';
Expand All @@ -21,12 +22,28 @@ import {
CannotStartAgentPopover,
} from './CannotStartAgentPopover';
import { requiredGas } from './constants';
import { LastTransaction } from './LastTransaction';

const { Text } = Typography;
const { Text, Paragraph } = Typography;

const LOADING_MESSAGE =
"Starting the agent may take a while, so feel free to minimize the app. We'll notify you once it's running. Please, don't quit the app.";

const IdleTooltip = () => (
<Tooltip
placement="bottom"
arrow={false}
title={
<Paragraph className="text-sm m-0">
Your agent earned rewards for this epoch and stopped working. It’ll
return to work once the next epoch starts.
</Paragraph>
}
>
<InfoCircleOutlined />
</Tooltip>
);

const AgentStartingButton = () => (
<Popover
trigger={['hover', 'click']}
Expand Down Expand Up @@ -55,6 +72,7 @@ const AgentStoppingButton = () => (

const AgentRunningButton = () => {
const { showNotification } = useElectronApi();
const { isEligibleForRewards } = useReward();
const { service, setIsServicePollingPaused, setServiceStatus } =
useServices();

Expand All @@ -81,9 +99,20 @@ const AgentRunningButton = () => {
<Button type="default" size="large" onClick={handlePause}>
Pause
</Button>
<Typography.Text type="secondary" className="text-sm loading-ellipses">
Agent is working
</Typography.Text>

<Flex vertical>
{isEligibleForRewards ? (
<Text type="secondary" className="text-sm">
Agent is idle&nbsp;
<IdleTooltip />
</Text>
) : (
<Text type="secondary" className="text-sm loading-ellipses">
Agent is working
</Text>
)}
<LastTransaction />
</Flex>
</Flex>
);
};
Expand Down
18 changes: 16 additions & 2 deletions frontend/components/MainPage/header/AgentHead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Badge } from 'antd';
import Image from 'next/image';

import { DeploymentStatus } from '@/client';
import { useReward } from '@/hooks/useReward';
import { useServices } from '@/hooks/useServices';

const badgeOffset: [number, number] = [-5, 32.5];
Expand All @@ -24,13 +25,26 @@ const StoppedAgentHead = () => (
</Badge>
);

const IdleAgentHead = () => (
<Badge dot status="processing" color="green" offset={badgeOffset}>
<Image src="/idle-robot.svg" alt="Idle Robot" width={40} height={40} />
</Badge>
);

export const AgentHead = () => {
const { serviceStatus } = useServices();
const { isEligibleForRewards } = useReward();

if (
serviceStatus === DeploymentStatus.DEPLOYING ||
serviceStatus === DeploymentStatus.STOPPING
)
) {
return <TransitionalAgentHead />;
if (serviceStatus === DeploymentStatus.DEPLOYED) return <DeployedAgentHead />;
}

if (serviceStatus === DeploymentStatus.DEPLOYED) {
// If the agent is eligible for rewards, agent is idle
return isEligibleForRewards ? <IdleAgentHead /> : <DeployedAgentHead />;
}
return <StoppedAgentHead />;
};
79 changes: 79 additions & 0 deletions frontend/components/MainPage/header/LastTransaction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Skeleton, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { useInterval } from 'usehooks-ts';

import { useAddress } from '@/hooks/useAddress';
import { getLatestTransaction } from '@/service/Ethers';
import { TransactionInfo } from '@/types/TransactionInfo';
import { getTimeAgo } from '@/utils/time';

const { Text } = Typography;

const Loader = styled(Skeleton.Input)`
line-height: 1;
span {
width: 120px !important;
height: 12px !important;
margin-top: 6px !important;
}
`;

const POLLING_INTERVAL = 60 * 1000; // 1 minute

/**
* Component to display the last transaction time and link to the transaction on GnosisScan
* by agent safe.
*/
export const LastTransaction = () => {
const { multisigAddress } = useAddress();

const [isFetching, setIsFetching] = useState(true);
const [transaction, setTransaction] = useState<TransactionInfo | null>(null);

const fetchTransaction = useCallback(async () => {
if (!multisigAddress) return;

getLatestTransaction(multisigAddress)
.then((tx) => setTransaction(tx))
.catch((error) =>
console.error('Failed to get latest transaction', error),
)
.finally(() => setIsFetching(false));
}, [multisigAddress]);

// Fetch the latest transaction on mount
useEffect(() => {
fetchTransaction();
}, [fetchTransaction]);

// Poll for the latest transaction
useInterval(() => fetchTransaction(), POLLING_INTERVAL);

if (isFetching) {
return <Loader active size="small" />;
}

if (!transaction) {
return (
<Text type="secondary" className="text-xs">
No transactions recently!
</Text>
);
}

return (
<Text type="secondary" className="text-xs">
Last txn:&nbsp;
<Text
type="secondary"
className="text-xs pointer hover-underline"
onClick={() =>
window.open(`https://gnosisscan.io/tx/${transaction.hash}`)
}
>
{getTimeAgo(transaction.timestamp)}
</Text>
</Text>
);
};
2 changes: 1 addition & 1 deletion frontend/components/MainPage/sections/RewardsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const DisplayRewards = () => {
return (
<CardSection vertical gap={8} padding="16px 24px" align="start">
<Text type="secondary">
Staking rewards this work period&nbsp;
Staking rewards this epoch&nbsp;
<Tooltip
arrow={false}
title={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const StakingContractDetails = ({
const details = stakingContractInfoRecord[stakingProgramId];
return [
{
left: 'Rewards per work period',
left: 'Rewards per epoch',
right: `~ ${details.rewardsPerWorkPeriod?.toFixed(2)} OLAS`,
},
{
Expand Down
14 changes: 4 additions & 10 deletions frontend/components/SettingsPage/DebugInfoSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@ import {
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';

import { CHAINS } from '@/constants/chains';
import { COLOR } from '@/constants/colors';
import { UNICODE_SYMBOLS } from '@/constants/symbols';
import { Token } from '@/enums/Token';
import { useAddress } from '@/hooks/useAddress';
import { useBalance } from '@/hooks/useBalance';
import { useServices } from '@/hooks/useServices';
import { useWallet } from '@/hooks/useWallet';
import { WalletAddressNumberRecord } from '@/types/Records';
import { copyToClipboard } from '@/utils/copyToClipboard';
Expand Down Expand Up @@ -143,15 +142,14 @@ const DebugItem = ({

export const DebugInfoSection = () => {
const { wallets, masterEoaAddress, masterSafeAddress } = useWallet();
const { services } = useServices();
const { instanceAddress, multisigAddress } = useAddress();
const { walletBalances } = useBalance();

const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = useCallback(() => setIsModalOpen(true), []);
const handleCancel = useCallback(() => setIsModalOpen(false), []);

const data = useMemo(() => {
if (!services) return null;
if (!wallets?.length) return null;

const result = [];
Expand All @@ -170,18 +168,13 @@ export const DebugInfoSection = () => {
});
}

const instanceAddress =
services[0]?.chain_configs?.[CHAINS.GNOSIS.chainId]?.chain_data
?.instances?.[0];
if (instanceAddress) {
result.push({
title: 'Agent Instance EOA',
...getItemData(walletBalances, instanceAddress!),
});
}

const multisigAddress =
services[0]?.chain_configs?.[CHAINS.GNOSIS.chainId]?.chain_data?.multisig;
if (multisigAddress) {
result.push({
title: 'Agent Safe',
Expand All @@ -193,7 +186,8 @@ export const DebugInfoSection = () => {
}, [
masterEoaAddress,
masterSafeAddress,
services,
instanceAddress,
multisigAddress,
walletBalances,
wallets?.length,
]);
Expand Down
17 changes: 17 additions & 0 deletions frontend/hooks/useAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CHAINS } from '@/constants/chains';

import { useServices } from './useServices';

export const useAddress = () => {
const { service } = useServices();

/** agent safe multisig address */
const multisigAddress =
service?.chain_configs?.[CHAINS.GNOSIS.chainId]?.chain_data?.multisig;

/** agent instance EOA address */
const instanceAddress =
service?.chain_configs?.[CHAINS.GNOSIS.chainId]?.chain_data?.instances?.[0];

return { instanceAddress, multisigAddress };
};
9 changes: 9 additions & 0 deletions frontend/public/idle-robot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions frontend/service/Ethers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ContractInterface, ethers, providers, utils } from 'ethers';

import { gnosisProvider } from '@/constants/providers';
import { Address } from '@/types/Address';
import { TransactionInfo } from '@/types/TransactionInfo';

/**
* Returns native balance of the given address
Expand Down Expand Up @@ -89,6 +90,63 @@ const checkRpc = async (rpc: string): Promise<boolean> => {
}
};

const BACK_TRACK_BLOCKS = 9000;
const MAX_ROUNDS = 5;

const getLogsList = async (
contractAddress: Address,
fromBlock: number,
toBlock: number,
roundsLeft: number,
): Promise<providers.Log[]> => {
// Limit the number of recursive calls to prevent too many requests
if (roundsLeft === 0) return [];

const filter = {
address: contractAddress,
fromBlock,
toBlock,
};
const list = await gnosisProvider.getLogs(filter);

if (list.length > 0) return list;

return getLogsList(
contractAddress,
fromBlock - BACK_TRACK_BLOCKS,
fromBlock,
roundsLeft - 1,
);
};

/**
* Get the latest transaction details for the given contract address
*/
export const getLatestTransaction = async (
contractAddress: Address,
): Promise<TransactionInfo | null> => {
const latestBlock = await gnosisProvider.getBlockNumber();

const logs = await getLogsList(
contractAddress,
latestBlock - BACK_TRACK_BLOCKS,
latestBlock,
MAX_ROUNDS,
);

// No transactions found
if (logs.length === 0) return null;

// Get the last log entry and fetch the transaction details
const lastLog = logs[logs.length - 1];
const txHash = lastLog.transactionHash;
const receipt = await gnosisProvider.getTransactionReceipt(txHash);
const block = await gnosisProvider.getBlock(receipt.blockNumber);
const timestamp = block.timestamp;

return { hash: txHash, timestamp };
};

const readContract = ({
address,
abi,
Expand All @@ -105,4 +163,5 @@ export const EthersService = {
getErc20Balance,
checkRpc,
readContract,
getLatestTransaction,
};
Loading

0 comments on commit 412449a

Please sign in to comment.