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: