Skip to content

Commit

Permalink
Merge pull request #248 from valory-xyz/fix/infinity-loading-start-bu…
Browse files Browse the repository at this point in the history
…tton

fix: start button deployment status refactor + unblocking async requests in middleware
  • Loading branch information
truemiller authored Jul 26, 2024
2 parents f2870cc + 6f0895e commit 2f66cc3
Show file tree
Hide file tree
Showing 18 changed files with 635 additions and 473 deletions.
2 changes: 1 addition & 1 deletion electron/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const { paths } = require('./constants');
* - use "" (nothing as a suffix) for latest release candidate, for example "0.1.0rc26"
* - use "alpha" for alpha release, for example "0.1.0rc26-alpha"
*/
const OlasMiddlewareVersion = '0.1.0rc87';
const OlasMiddlewareVersion = '0.1.0rc92';

const Env = {
...process.env,
Expand Down
273 changes: 273 additions & 0 deletions frontend/components/Main/MainHeader/AgentButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, ButtonProps, Flex, Popover, 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 { useServices } from '@/hooks/useServices';
import { useServiceTemplates } from '@/hooks/useServiceTemplates';
import { useStakingContractInfo } from '@/hooks/useStakingContractInfo';
import { useStore } from '@/hooks/useStore';
import { useWallet } from '@/hooks/useWallet';
import { ServicesService } from '@/service/Services';
import { WalletService } from '@/service/Wallet';
import { getMinimumStakedAmountRequired } from '@/utils/service';

import { CannotStartAgent } from '../CannotStartAgent';
import { requiredGas, requiredOlas } from '../constants';

const { Text } = 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 AgentStartingButton = () => (
<Popover
trigger={['hover', 'click']}
placement="bottomLeft"
showArrow={false}
content={
<Flex vertical={false} gap={8} style={{ maxWidth: 260 }}>
<Text>
<InfoCircleOutlined style={{ color: COLOR.BLUE }} />
</Text>
<Text>{LOADING_MESSAGE}</Text>
</Flex>
}
>
<Button type="default" size="large" ghost disabled loading>
Starting...
</Button>
</Popover>
);

const AgentStoppingButton = () => (
<Button type="default" size="large" ghost disabled loading>
Stopping...
</Button>
);

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

const handlePause = useCallback(async () => {
if (!service) return;
// Paused to stop overlapping service poll while waiting for response
setIsServicePollingPaused(true);

// Optimistically update service status
setServiceStatus(DeploymentStatus.STOPPING);
try {
await ServicesService.stopDeployment(service.hash);
} catch (error) {
console.error(error);
showNotification?.('Error while stopping agent');
} finally {
// Resume polling, will update to correct status regardless of success
setIsServicePollingPaused(false);
}
}, [service, setIsServicePollingPaused, setServiceStatus, showNotification]);

return (
<Flex gap={10} align="center">
<Button type="default" size="large" onClick={handlePause}>
Pause
</Button>
<Typography.Text type="secondary" className="text-sm loading-ellipses">
Agent is working
</Typography.Text>
</Flex>
);
};

const AgentNotRunningButton = () => {
const { wallets, masterSafeAddress } = useWallet();
const {
service,
serviceStatus,
setServiceStatus,
setIsServicePollingPaused,
} = useServices();
const { serviceTemplate } = useServiceTemplates();
const { showNotification } = useElectronApi();
const {
setIsPaused: setIsBalancePollingPaused,
safeBalance,
totalOlasStakedBalance,
totalEthBalance,
} = useBalance();
const { storeState } = useStore();
const { isEligibleForStaking, isAgentEvicted } = useStakingContractInfo();

const safeOlasBalance = safeBalance?.OLAS;
const safeOlasBalanceWithStaked =
safeOlasBalance === undefined || totalOlasStakedBalance === undefined
? undefined
: safeOlasBalance + totalOlasStakedBalance;

const handleStart = useCallback(async () => {
// Must have a wallet to start the agent
if (!wallets?.[0]) return;

// Paused to stop overlapping service poll while wallet is created or service is built
setIsServicePollingPaused(true);

// Paused to stop confusing balance transitions while starting the agent
setIsBalancePollingPaused(true);

// Mock "DEPLOYING" status (service polling will update this once resumed)
setServiceStatus(DeploymentStatus.DEPLOYING);

// Create master safe if it doesn't exist
try {
if (!masterSafeAddress) {
await WalletService.createSafe(Chain.GNOSIS);
}
} catch (error) {
console.error(error);
setServiceStatus(undefined);
showNotification?.('Error while creating safe');
setIsServicePollingPaused(false);
setIsBalancePollingPaused(false);
return;
}

// Then create / deploy the service
try {
await ServicesService.createService({
serviceTemplate,
deploy: true,
});
} catch (error) {
console.error(error);
setServiceStatus(undefined);
showNotification?.('Error while deploying service');
setIsServicePollingPaused(false);
setIsBalancePollingPaused(false);
return;
}

// Show success notification based on whether there was a service prior to starting
try {
if (!service) {
showNotification?.('Your agent is now running!');
} else {
const minimumStakedAmountRequired =
getMinimumStakedAmountRequired(serviceTemplate);

showNotification?.(
`Your agent is running and you've staked ${minimumStakedAmountRequired} OLAS!`,
);
}
} catch (error) {
console.error(error);
showNotification?.('Error while showing "running" notification');
}

// Can assume successful deployment
// resume polling, optimistically update service status (poll will update, if needed)
setIsServicePollingPaused(false);
setIsBalancePollingPaused(false);
setServiceStatus(DeploymentStatus.DEPLOYED);
}, [
wallets,
setIsServicePollingPaused,
setIsBalancePollingPaused,
setServiceStatus,
masterSafeAddress,
showNotification,
serviceTemplate,
service,
]);

const isDeployable = useMemo(() => {
if (serviceStatus === DeploymentStatus.DEPLOYED) return false;
if (serviceStatus === DeploymentStatus.DEPLOYING) return false;
if (serviceStatus === DeploymentStatus.STOPPING) return false;

// case where service exists & user has initial funded
if (service && storeState?.isInitialFunded) {
if (!safeOlasBalanceWithStaked) return false;
// at present agent will always require staked/bonded OLAS (or the ability to stake/bond)
return safeOlasBalanceWithStaked >= requiredOlas;
}

// case if agent is evicted and user has met the staking criteria
if (isEligibleForStaking && isAgentEvicted) return true;

const hasEnoughOlas = (safeOlasBalanceWithStaked ?? 0) >= requiredOlas;
const hasEnoughEth = (totalEthBalance ?? 0) > requiredGas;

return hasEnoughOlas && hasEnoughEth;
}, [
isAgentEvicted,
isEligibleForStaking,
safeOlasBalanceWithStaked,
service,
serviceStatus,
storeState?.isInitialFunded,
totalEthBalance,
]);

const buttonProps: ButtonProps = {
type: 'primary',
size: 'large',
disabled: !isDeployable,
onClick: isDeployable ? handleStart : undefined,
};

const buttonText = `Start agent ${service ? '' : '& stake'}`;

return <Button {...buttonProps}>{buttonText}</Button>;
};

export const AgentButton = () => {
const { service, serviceStatus, hasInitialLoaded } = useServices();
const { isEligibleForStaking, isAgentEvicted } = useStakingContractInfo();

return useMemo(() => {
if (!hasInitialLoaded) {
return <Button type="primary" size="large" disabled loading />;
}

if (serviceStatus === DeploymentStatus.STOPPING) {
return <AgentStoppingButton />;
}

if (serviceStatus === DeploymentStatus.DEPLOYING) {
return <AgentStartingButton />;
}

if (serviceStatus === DeploymentStatus.DEPLOYED) {
return <AgentRunningButton />;
}

if (!isEligibleForStaking && isAgentEvicted) return <CannotStartAgent />;

if (
!service ||
serviceStatus === DeploymentStatus.STOPPED ||
serviceStatus === DeploymentStatus.CREATED ||
serviceStatus === DeploymentStatus.BUILT ||
serviceStatus === DeploymentStatus.DELETED
) {
return <AgentNotRunningButton />;
}

return (
<Button type="primary" size="large" disabled>
Error, contact us!
</Button>
);
}, [
hasInitialLoaded,
serviceStatus,
isEligibleForStaking,
isAgentEvicted,
service,
]);
};
36 changes: 36 additions & 0 deletions frontend/components/Main/MainHeader/AgentHead/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Badge } from 'antd';
import Image from 'next/image';

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

const badgeOffset: [number, number] = [-5, 32.5];

const TransitionalAgentHead = () => (
<Badge status="processing" color="orange" dot offset={badgeOffset}>
<Image src="/happy-robot.svg" alt="Happy Robot" width={40} height={40} />
</Badge>
);

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

const StoppedAgentHead = () => (
<Badge dot color="red" offset={badgeOffset}>
<Image src="/sad-robot.svg" alt="Sad Robot" width={40} height={40} />
</Badge>
);

export const AgentHead = () => {
const { serviceStatus } = useServices();
if (
serviceStatus === DeploymentStatus.DEPLOYING ||
serviceStatus === DeploymentStatus.STOPPING
)
return <TransitionalAgentHead />;
if (serviceStatus === DeploymentStatus.DEPLOYED) return <DeployedAgentHead />;
return <StoppedAgentHead />;
};
4 changes: 2 additions & 2 deletions frontend/components/Main/MainHeader/CannotStartAgent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ const NoJobsAvailablePopover = () => (

export const CannotStartAgent = () => {
const {
isEligibleForStakingAction,
isEligibleForStaking,
hasEnoughServiceSlots,
isRewardsAvailable,
isAgentEvicted,
} = useStakingContractInfo();

if (isEligibleForStakingAction) return null;
if (isEligibleForStaking) return null;
if (!hasEnoughServiceSlots) return <NoJobsAvailablePopover />;
if (!isRewardsAvailable) return <NoRewardsAvailablePopover />;
if (isAgentEvicted) return <AgentEvictedPopover />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { formatUnits } from 'ethers/lib/utils';

import { SERVICE_TEMPLATES } from '@/constants/serviceTemplates';

// TODO: Move this to more appropriate location (root /constants)

const olasCostOfBond = Number(
formatUnits(`${SERVICE_TEMPLATES[0].configuration.olas_cost_of_bond}`, 18),
);
Expand Down
Loading

0 comments on commit 2f66cc3

Please sign in to comment.