diff --git a/electron/main.js b/electron/main.js index 23192531c..69be1732d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -92,6 +92,7 @@ function showNotification(title, body) { async function beforeQuit() { if (operateDaemonPid) { try { + await fetch(`http://localhost:${appConfig.ports.prod.operate}/stop_all_services`); await killProcesses(operateDaemonPid); } catch (e) { logger.electron(e); diff --git a/frontend/components/MainPage/header/CannotStartAgentPopover.tsx b/frontend/components/MainPage/header/CannotStartAgentPopover.tsx index 0f880a7a2..db882f9c9 100644 --- a/frontend/components/MainPage/header/CannotStartAgentPopover.tsx +++ b/frontend/components/MainPage/header/CannotStartAgentPopover.tsx @@ -1,10 +1,11 @@ import { InfoCircleOutlined } from '@ant-design/icons'; -import { Popover, PopoverProps, Typography } from 'antd'; +import { Flex, Popover, PopoverProps, Typography } from 'antd'; import { COLOR } from '@/constants/colors'; import { UNICODE_SYMBOLS } from '@/constants/symbols'; import { SUPPORT_URL } from '@/constants/urls'; import { useStakingContractInfo } from '@/hooks/useStakingContractInfo'; +import { formatToShortDateTime } from '@/utils/time'; const { Paragraph, Text } = Typography; @@ -42,16 +43,37 @@ export const CannotStartAgentDueToUnexpectedError = () => ( ); const evictedDescription = - "You didn't run your agent enough and it missed its targets multiple times. Please wait a few days and try to run your agent again."; -const AgentEvictedPopover = () => ( - {evictedDescription}} - > - {cannotStartAgentText} - -); + "You didn't run your agent enough and it missed its targets multiple times. You can run the agent again when the eviction period ends."; +const AgentEvictedPopover = () => { + const { evictionExpiresAt } = useStakingContractInfo(); + + return ( + + {evictedDescription} + {evictionExpiresAt && ( + + Eviction ends at{' '} + + {formatToShortDateTime(evictionExpiresAt * 1000)} + + + )} + + } + > + {cannotStartAgentText} + + ); +}; const JoinOlasCommunity = () => (
diff --git a/frontend/components/MainPage/index.tsx b/frontend/components/MainPage/index.tsx index 1dc32d69b..30f61f7d2 100644 --- a/frontend/components/MainPage/index.tsx +++ b/frontend/components/MainPage/index.tsx @@ -17,7 +17,7 @@ import { GasBalanceSection } from './sections/GasBalanceSection'; import { KeepAgentRunningSection } from './sections/KeepAgentRunningSection'; import { MainNeedsFunds } from './sections/NeedsFundsSection'; import { MainOlasBalance } from './sections/OlasBalanceSection'; -import { MainRewards } from './sections/RewardsSection'; +import { MainRewards } from './sections/RewardsSection/RewardsSection'; import { StakingContractUpdate } from './sections/StakingContractUpdate'; export const Main = () => { diff --git a/frontend/components/MainPage/sections/RewardsSection.tsx b/frontend/components/MainPage/sections/RewardsSection/RewardsSection.tsx similarity index 87% rename from frontend/components/MainPage/sections/RewardsSection.tsx rename to frontend/components/MainPage/sections/RewardsSection/RewardsSection.tsx index 3376812e4..2e73598f5 100644 --- a/frontend/components/MainPage/sections/RewardsSection.tsx +++ b/frontend/components/MainPage/sections/RewardsSection/RewardsSection.tsx @@ -1,5 +1,5 @@ -import { InfoCircleOutlined, RightOutlined } from '@ant-design/icons'; -import { Button, Flex, Modal, Skeleton, Tag, Tooltip, Typography } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; +import { Button, Flex, Modal, Skeleton, Tag, Typography } from 'antd'; import Image from 'next/image'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,10 +11,11 @@ import { useReward } from '@/hooks/useReward'; import { useStore } from '@/hooks/useStore'; import { balanceFormat } from '@/utils/numberFormatters'; -import { ConfettiAnimation } from '../../Confetti/ConfettiAnimation'; -import { CardSection } from '../../styled/CardSection'; +import { ConfettiAnimation } from '../../../Confetti/ConfettiAnimation'; +import { CardSection } from '../../../styled/CardSection'; +import { StakingRewardsThisEpoch } from './StakingRewardsThisEpoch'; -const { Text, Title, Paragraph } = Typography; +const { Text, Title } = Typography; const Loader = () => ( @@ -35,20 +36,7 @@ const DisplayRewards = () => { return ( - - Staking rewards this epoch  - - The agent's working period lasts at least 24 hours, but its - start and end point may not be at the same time every day. - - } - > - - - + {isBalanceLoaded ? ( diff --git a/frontend/components/MainPage/sections/RewardsSection/StakingRewardsThisEpoch.tsx b/frontend/components/MainPage/sections/RewardsSection/StakingRewardsThisEpoch.tsx new file mode 100644 index 000000000..0cffa1089 --- /dev/null +++ b/frontend/components/MainPage/sections/RewardsSection/StakingRewardsThisEpoch.tsx @@ -0,0 +1,80 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { Popover, Typography } from 'antd'; +import { gql, request } from 'graphql-request'; +import { z } from 'zod'; + +import { SUBGRAPH_URL } from '@/constants/urls'; +import { POPOVER_WIDTH_MEDIUM } from '@/constants/width'; +import { useStakingProgram } from '@/hooks/useStakingProgram'; +import { formatToTime } from '@/utils/time'; + +const { Text } = Typography; + +const EpochTimeResponseSchema = z.object({ + epochLength: z.string(), + blockTimestamp: z.string(), +}); +type EpochTimeResponse = z.infer; + +const useEpochEndTime = () => { + const { activeStakingProgramAddress } = useStakingProgram(); + const latestEpochTimeQuery = gql` + query { + checkpoints( + orderBy: epoch + orderDirection: desc + first: 1 + where: { + contractAddress: "${activeStakingProgramAddress}" + } + ) { + epochLength + blockTimestamp + } + } + `; + + const { data, isLoading } = useQuery({ + queryKey: ['latestEpochTime'], + queryFn: async () => { + const response = (await request(SUBGRAPH_URL, latestEpochTimeQuery)) as { + checkpoints: EpochTimeResponse[]; + }; + return EpochTimeResponseSchema.parse(response.checkpoints[0]); + }, + select: (data) => { + // last epoch end time + epoch length + return Number(data.blockTimestamp) + Number(data.epochLength); + }, + enabled: !!activeStakingProgramAddress, + }); + + return { data, isLoading }; +}; + +export const StakingRewardsThisEpoch = () => { + const { data: epochEndTimeInMs } = useEpochEndTime(); + const { activeStakingProgramMeta } = useStakingProgram(); + + return ( + + Staking rewards this epoch  + + The epoch for {activeStakingProgramMeta?.name} ends each day at ~{' '} + + {epochEndTimeInMs + ? `${formatToTime(epochEndTimeInMs * 1000)} (UTC)` + : '--'} + +
+ } + > + + + + ); +}; diff --git a/frontend/components/RewardsHistory/useRewardsHistory.ts b/frontend/components/RewardsHistory/useRewardsHistory.ts index 3feed5d60..761d24ac3 100644 --- a/frontend/components/RewardsHistory/useRewardsHistory.ts +++ b/frontend/components/RewardsHistory/useRewardsHistory.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { Chain } from '@/client'; import { SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES } from '@/constants/contractAddresses'; import { STAKING_PROGRAM_META } from '@/constants/stakingProgramMeta'; +import { SUBGRAPH_URL } from '@/constants/urls'; import { StakingProgramId } from '@/enums/StakingProgram'; import { useServices } from '@/hooks/useServices'; @@ -30,9 +31,6 @@ const beta2Address = SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES[Chain.GNOSIS] .pearl_beta_2; -const SUBGRAPH_URL = - 'https://api.studio.thegraph.com/query/81855/pearl-staking-rewards-history/version/latest'; - const fetchRewardsQuery = gql` { allRewards: checkpoints(orderBy: epoch, orderDirection: desc) { diff --git a/frontend/constants/headers.ts b/frontend/constants/headers.ts index 062ef9f69..aedf09f9c 100644 --- a/frontend/constants/headers.ts +++ b/frontend/constants/headers.ts @@ -1,3 +1,3 @@ export const CONTENT_TYPE_JSON_UTF8 = { 'Content-Type': 'application/json; charset=utf-8', -}; +} as const; diff --git a/frontend/constants/urls.ts b/frontend/constants/urls.ts index acbeef27e..f6407b42a 100644 --- a/frontend/constants/urls.ts +++ b/frontend/constants/urls.ts @@ -6,6 +6,9 @@ export const COW_SWAP_GNOSIS_XDAI_OLAS_URL: string = export const FAQ_URL = 'https://olas.network/operate#faq'; export const DOWNLOAD_URL = 'https://olas.network/operate#download'; +export const SUBGRAPH_URL = + 'https://api.studio.thegraph.com/query/81855/pearl-staking-rewards-history/version/latest'; + // discord export const SUPPORT_URL = 'https://discord.com/channels/899649805582737479/1244588374736502847'; diff --git a/frontend/constants/width.ts b/frontend/constants/width.ts index 015adf0ad..65295b698 100644 --- a/frontend/constants/width.ts +++ b/frontend/constants/width.ts @@ -1 +1,3 @@ export const MODAL_WIDTH = 412; + +export const POPOVER_WIDTH_MEDIUM = 260; diff --git a/frontend/hooks/useStakingContractInfo.ts b/frontend/hooks/useStakingContractInfo.ts index 75a67674c..72f24d6b8 100644 --- a/frontend/hooks/useStakingContractInfo.ts +++ b/frontend/hooks/useStakingContractInfo.ts @@ -43,9 +43,7 @@ export const useStakingContractInfo = () => { serviceIds.length < maxNumServices; const hasEnoughRewardsAndSlots = isRewardsAvailable && hasEnoughServiceSlots; - const isAgentEvicted = serviceStakingState === 2; - const isServiceStaked = !!serviceStakingStartTime && serviceStakingState === 1; @@ -78,10 +76,15 @@ export const useStakingContractInfo = () => { !isNil(hasEnoughRewardsAndSlots) && (isAgentEvicted ? isServiceStakedForMinimumDuration : true); + // Eviction expire time in seconds + const evictionExpiresAt = + (serviceStakingStartTime ?? 0) + (minimumStakingDuration ?? 0); + return { activeStakingContractInfo, hasEnoughServiceSlots, isAgentEvicted, + evictionExpiresAt, isEligibleForStaking, isPaused, isRewardsAvailable, diff --git a/frontend/hooks/useStakingProgram.ts b/frontend/hooks/useStakingProgram.ts index 560c8c811..b99ca6752 100644 --- a/frontend/hooks/useStakingProgram.ts +++ b/frontend/hooks/useStakingProgram.ts @@ -1,5 +1,7 @@ import { useContext, useMemo } from 'react'; +import { Chain } from '@/client'; +import { SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES } from '@/constants/contractAddresses'; import { STAKING_PROGRAM_META } from '@/constants/stakingProgramMeta'; import { StakingProgramContext } from '@/context/StakingProgramContext'; @@ -23,7 +25,7 @@ export const useStakingProgram = () => { * returns `null` if not actively staked */ const activeStakingProgramMeta = useMemo(() => { - if (activeStakingProgramId === undefined) return undefined; + if (activeStakingProgramId === undefined) return; if (activeStakingProgramId === null) return null; return STAKING_PROGRAM_META[activeStakingProgramId]; }, [activeStakingProgramId]); @@ -31,8 +33,16 @@ export const useStakingProgram = () => { const defaultStakingProgramMeta = STAKING_PROGRAM_META[defaultStakingProgramId]; + const activeStakingProgramAddress = useMemo(() => { + if (!activeStakingProgramId) return; + return SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES[Chain.GNOSIS][ + activeStakingProgramId + ]; + }, [activeStakingProgramId]); + return { activeStakingProgramId, + activeStakingProgramAddress, activeStakingProgramMeta, defaultStakingProgramId, defaultStakingProgramMeta, diff --git a/frontend/styles/globals.scss b/frontend/styles/globals.scss index bb25a8dab..aa65eb791 100644 --- a/frontend/styles/globals.scss +++ b/frontend/styles/globals.scss @@ -181,18 +181,16 @@ textarea, margin-right: auto !important; } +// font size .text-xl { font-size: 20px; } - .text-base { font-size: 16px !important; } - .text-sm { font-size: 14px !important; } - .text-xs { font-size: 12px !important; } diff --git a/frontend/theme/index.ts b/frontend/theme/index.ts index 0291c95cd..eed4d88ba 100644 --- a/frontend/theme/index.ts +++ b/frontend/theme/index.ts @@ -40,6 +40,9 @@ export const mainTheme: ThemeConfig = { Typography: { colorTextDescription: '#4D596A', }, + Popover: { + fontSize: 14, + }, Tag: { colorSuccess: '#135200', }, diff --git a/frontend/utils/time.ts b/frontend/utils/time.ts index a09b9ab62..7a9faa4ec 100644 --- a/frontend/utils/time.ts +++ b/frontend/utils/time.ts @@ -27,22 +27,36 @@ export const getTimeAgo = (timestampInSeconds: number) => { * @returns formatted date in the format of 'MMM DD' * @example 1626825600 => 'Jul 21' */ -export const formatToMonthDay = (timeInSeconds: number) => { - if (!isNumber(timeInSeconds)) return '--'; - return new Date(timeInSeconds).toLocaleDateString('en-US', { +export const formatToMonthDay = (timeInMs: number) => { + if (!isNumber(timeInMs)) return '--'; + return new Date(timeInMs).toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); }; +/** + * @returns formatted time in the format of 'HH:MM AM/PM' + * @example 1626825600 => '12:00 PM' + */ +export const formatToTime = (timeInMs: number) => { + if (!isNumber(timeInMs)) return '--'; + return new Date(timeInMs).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: 'numeric', + hour12: true, + timeZone: 'UTC', + }); +}; + /** * * @returns formatted date and time in the format of 'MMM DD, HH:MM AM/PM' * @example 1626825600 => 'Jul 21, 12:00 PM' */ -export const formatToShortDateTime = (timeInSeconds?: number) => { - if (!isNumber(timeInSeconds)) return '--'; - return new Date(timeInSeconds).toLocaleDateString('en-US', { +export const formatToShortDateTime = (timeInMs?: number) => { + if (!isNumber(timeInMs)) return '--'; + return new Date(timeInMs).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', diff --git a/operate/cli.py b/operate/cli.py index 2c491ad3b..ce4725d5f 100644 --- a/operate/cli.py +++ b/operate/cli.py @@ -28,6 +28,7 @@ import uuid from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from types import FrameType from aea.helpers.logging import setup_logger from clea import group, params, run @@ -207,6 +208,10 @@ def cancel_funding_job(service: str) -> None: def pause_all_services_on_startup() -> None: logger.info("Stopping services on startup...") + pause_all_services() + logger.info("Stopping services on startup done.") + + def pause_all_services() -> None: service_hashes = [i["hash"] for i in operate.service_manager().json] for service in service_hashes: @@ -220,7 +225,14 @@ def pause_all_services_on_startup() -> None: logger.info(f"Cancelling funding job for {service}") cancel_funding_job(service=service) health_checker.stop_for_service(service=service) - logger.info("Stopping services on startup done.") + + def pause_all_services_on_exit(signum: int, frame: t.Optional[FrameType]) -> None: + logger.info("Stopping services on exit...") + pause_all_services() + logger.info("Stopping services on exit done.") + + signal.signal(signal.SIGINT, pause_all_services_on_exit) + signal.signal(signal.SIGTERM, pause_all_services_on_exit) # on backend app started we assume there are now started agents, so we force to pause all pause_all_services_on_startup() @@ -268,6 +280,13 @@ async def _kill_server(request: Request) -> JSONResponse: """Kill backend server from inside.""" os.kill(os.getpid(), signal.SIGINT) + @app.get("/stop_all_services") + async def _stop_all_services(request: Request) -> JSONResponse: + """Kill backend server from inside.""" + logger.info("Stopping services on demand...") + pause_all_services() + logger.info("Stopping services on demand done.") + @app.get("/api") @with_retries async def _get_api(request: Request) -> JSONResponse: diff --git a/operate/services/deployment_runner.py b/operate/services/deployment_runner.py index b53f9b12e..ebde6527b 100644 --- a/operate/services/deployment_runner.py +++ b/operate/services/deployment_runner.py @@ -78,6 +78,8 @@ def kill_process(pid: int) -> None: children = list(reversed(current_process.children(recursive=True))) for child in children: _kill_process(child.pid) + _kill_process(child.pid) + _kill_process(pid) _kill_process(pid) diff --git a/operate/services/service.py b/operate/services/service.py index ab2d26315..346fa115b 100644 --- a/operate/services/service.py +++ b/operate/services/service.py @@ -29,6 +29,8 @@ from copy import copy, deepcopy from dataclasses import dataclass from pathlib import Path +from time import sleep +from traceback import print_exc from aea.configurations.constants import ( DEFAULT_LEDGER, @@ -504,7 +506,15 @@ def _build_host(self, force: bool = True, chain_id: str = "100") -> None: if build.exists() and force: stop_host_deployment(build_dir=build) - shutil.rmtree(build) + try: + # sleep needed to ensure all processes closed/killed otherwise it will block directory removal on windows + sleep(3) + shutil.rmtree(build) + except: # noqa # pylint: disable=bare-except + # sleep and try again. exception if fails + print_exc() + sleep(3) + shutil.rmtree(build) service = Service.load(path=self.path) if service.helper.config.number_of_agents > 1: