diff --git a/.env.example b/.env.example index 5b42f8092..a0b44f771 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,7 @@ REACT_APP_BASE_API_URL=https://api.oraidex.io REACT_APP_KADO_API_KEY=df0d2b3f-d829-4453-a4f6-1d6e8870e8f4 REACT_APP_MIX_PANEL_ENVIRONMENT=acbafd21a85654933cbb0332c5a6f4f8 -REACT_APP_STRAPI_BASE_URL=https://nice-fireworks-d26703b63e.strapiapp.com \ No newline at end of file +REACT_APP_STRAPI_BASE_URL=https://nice-fireworks-d26703b63e.strapiapp.com + +REACT_APP_GITHUB_CLIENT_ID=Ov23liRkvPuKiJCYPmXi +REACT_APP_BASE_GPU_API_URL=https://api-gpu-hub.orai.io diff --git a/package.json b/package.json index 88e649c6c..b7e0ab65f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@sentry/react": "^7.47.0", "@tanstack/react-query": "^4.32.6", "@tharsis/proto": "^0.1.17", - "@tippyjs/react": "^4.2.0", + "@tippyjs/react": "^4.2.6", "@walletconnect/browser-utils": "^1.8.0", "@walletconnect/ethereum-provider": "^1.7.8", "axios": "^0.26.1", @@ -33,7 +33,9 @@ "big-integer": "^1.6.52", "bitcoin-units": "^1.0.0", "chain-registry": "^1.63.86", + "chart.js": "^4.4.4", "classnames": "^2.2.6", + "dotenv": "^16.4.5", "ethers": "^5.0.15", "idb-keyval": "^6.2.1", "lightweight-charts": "^4.1.3", @@ -43,6 +45,7 @@ "qr-code-styling": "1.6.0-rc.1", "qrcode": "^1.5.3", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.1.1", "react-modal": "^3.16.1", diff --git a/src/assets/icons/github.svg b/src/assets/icons/github.svg new file mode 100644 index 000000000..b85c07f00 --- /dev/null +++ b/src/assets/icons/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/gpu_credit.svg b/src/assets/icons/gpu_credit.svg new file mode 100644 index 000000000..d90720dae --- /dev/null +++ b/src/assets/icons/gpu_credit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/gpu_staking.svg b/src/assets/icons/gpu_staking.svg index d90720dae..26f7959c2 100644 --- a/src/assets/icons/gpu_staking.svg +++ b/src/assets/icons/gpu_staking.svg @@ -1,10 +1,5 @@ - - - - - - - - - + + + + diff --git a/src/assets/icons/key.svg b/src/assets/icons/key.svg new file mode 100644 index 000000000..41d456774 --- /dev/null +++ b/src/assets/icons/key.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/logout-git.svg b/src/assets/icons/logout-git.svg new file mode 100644 index 000000000..5f5e2b10e --- /dev/null +++ b/src/assets/icons/logout-git.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/nav-arrow-down.svg b/src/assets/icons/nav-arrow-down.svg new file mode 100644 index 000000000..8f923a3d8 --- /dev/null +++ b/src/assets/icons/nav-arrow-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/bg-gpu-credit-balance.png b/src/assets/images/bg-gpu-credit-balance.png new file mode 100644 index 000000000..1f7ad1a0c Binary files /dev/null and b/src/assets/images/bg-gpu-credit-balance.png differ diff --git a/src/assets/images/connected-img.png b/src/assets/images/connected-img.png new file mode 100644 index 000000000..b27d9a5e6 Binary files /dev/null and b/src/assets/images/connected-img.png differ diff --git a/src/assets/images/flicker-dot.svg b/src/assets/images/flicker-dot.svg new file mode 100644 index 000000000..bff565893 --- /dev/null +++ b/src/assets/images/flicker-dot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/jupyterhub.png b/src/assets/images/jupyterhub.png new file mode 100644 index 000000000..9238ebee8 Binary files /dev/null and b/src/assets/images/jupyterhub.png differ diff --git a/src/assets/images/no-credit-usage-history.svg b/src/assets/images/no-credit-usage-history.svg new file mode 100644 index 000000000..f241f00ed --- /dev/null +++ b/src/assets/images/no-credit-usage-history.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss index 81b5171de..b77dfe1b6 100644 --- a/src/components/Button/Button.module.scss +++ b/src/components/Button/Button.module.scss @@ -147,3 +147,21 @@ padding: 4px 16px; border-radius: 8px; } + +.fourth { + padding: var(--dimension-padding-btn-y-padding-l, 16px) var(--dimension-padding-btn-x-padding-m, 12px); + color: var(--Colors-Primary-Text-action, #b999f3); + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + border: 1px solid transparent; + border-radius: var(--Dimension-Corner-Radius-button, 8px); + + &:hover { + opacity: 1; + border: 1px solid var(--Colors-Neutral-Border-default, #383b40); + background: var(--Colors-Neutral-Surface-hover, #47474b); + } +} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 85843c182..a51838670 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -14,10 +14,11 @@ type ButtonType = | 'third' | 'third-sm' | 'error' - | 'error-sm'; + | 'error-sm' + | 'fourth'; interface Props { type: ButtonType; - onClick: (event: React.MouseEvent) => void; + onClick?: (event: React.MouseEvent) => void; children: React.ReactElement | React.ReactNode; disabled?: boolean; icon?: React.ReactElement | React.ReactNode; diff --git a/src/components/GithubConnect/helper.ts b/src/components/GithubConnect/helper.ts new file mode 100644 index 000000000..e179babe9 --- /dev/null +++ b/src/components/GithubConnect/helper.ts @@ -0,0 +1,11 @@ +import crypto from 'crypto'; +import { setLatestCsrf } from 'utils/githubCode'; + +export const handleConnectGithub = () => { + const csrf = crypto.randomBytes(16).toString('hex'); + setLatestCsrf(csrf); + + window.location.assign( + `https://github.com/login/oauth/authorize?client_id=${process.env.REACT_APP_GITHUB_CLIENT_ID}&state=${csrf}` + ); +}; diff --git a/src/components/GithubConnect/index.module.scss b/src/components/GithubConnect/index.module.scss new file mode 100644 index 000000000..f09f043cc --- /dev/null +++ b/src/components/GithubConnect/index.module.scss @@ -0,0 +1,101 @@ +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.wrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-right: 1px solid var(--Colors-Neutral-Border-light, #242627); +} + +.connected-btn { + margin-right: 8px; +} + +.connected-area { + padding: 6px var(--Dimension-Spacing-input-x-spacing, 16px); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border-left: 1px solid var(--Colors-Neutral-Border-light, #242627); + cursor: pointer; + + &:hover { + opacity: 0.8; + } +} + +.connected-img { + width: 36px; + height: 36px; + border-radius: var(--Dimension-Corner-Radius-badge, 99px); + border: 1px solid var(--Colors-Neutral-Border-default, #383b40); +} + +.connected-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.connected-info { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 2px; + + text-align: center; + font-size: 14px; + font-style: normal; + line-height: 150%; /* 21px */ + + .connected-info--name { + color: var(--Colors-Neutral-Text-title, #efefef); + font-weight: 400; + } + + .connected-info--credit { + color: var(--Colors-Neutral-Text-text-token-name, #f7f7f7); + font-weight: 600; + } +} + +.connected-modal { + width: 180px; + display: flex; + flex-direction: column; + padding: 8px; + flex-direction: column; + align-items: flex-start; + gap: 4px; + border-radius: 8px; + border: 1px solid var(--Colors-Neutral-Border-default, #383b40); + background-color: var(--Colors-Neutral-Surface-card, #18181a); +} + +.connected-modal-option { + display: flex; + height: var(--Dimensions-40, 40px); + padding: 12px 8px; + align-items: center; + justify-content: space-between; + gap: 12px; + align-self: stretch; + cursor: pointer; + + &:hover { + opacity: 0.8; + } +} + +.modal-option-name { + color: var(--Colors-Neutral-Text-title, #f7f7f7); + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 120%; /* 19.2px */ +} diff --git a/src/components/GithubConnect/index.tsx b/src/components/GithubConnect/index.tsx new file mode 100644 index 000000000..93660105a --- /dev/null +++ b/src/components/GithubConnect/index.tsx @@ -0,0 +1,114 @@ +import React, { useEffect } from 'react'; +import classNames from 'classnames/bind'; +import HeadlessTippy from '@tippyjs/react/headless'; +import { useDispatch, useSelector } from 'react-redux'; +import { toast } from 'react-toastify'; + +import { Button } from 'components/Button'; +import { RootState } from 'store/configure'; +import { setCredit, reset } from 'reducer/auth'; +import { axiosAuth as axios } from 'rest/request'; +import { ReactComponent as GitHubIcon } from 'assets/icons/github.svg'; +import ConnectedImg from 'assets/images/connected-img.png'; +import DropdownIcon from 'assets/icons/nav-arrow-down.svg'; +import LogoutIcon from 'assets/icons/logout-git.svg'; +import { handleConnectGithub } from './helper'; +import styles from './index.module.scss'; +import 'tippy.js/dist/tippy.css'; // optional for styling + +const cx = classNames.bind(styles); + +const baseApiUrl = process.env.REACT_APP_BASE_GPU_API_URL; +export const GithubConnect: React.FC = () => { + const dispatch = useDispatch(); + const accountName = useSelector((state: RootState) => state.auth.accountName); + const credit = useSelector((state: RootState) => state.auth.credit); + const { access: accessToken } = useSelector((state: RootState) => state.auth.token); + + useEffect(() => { + const getCreditData = async () => { + let resp; + try { + resp = await axios.get(`${baseApiUrl}/credit-remain`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + } catch (e) { + console.error('Credit remain:', e); + + toast.error('Failed to query credit balance'); + } + resp && dispatch(setCredit(resp.data.creditRemain)); + }; + accessToken && getCreditData(); + }, [accessToken]); + + return ( +
+
+ +
+ + {accountName ? ( + { + const options: { name: string; icon?: string; onCLick?: React.MouseEventHandler }[] = [ + { + name: 'Manage your credits' + }, + { + name: 'Log out', + icon: LogoutIcon, + onCLick: () => { + // remove token + dispatch(reset()); + } + } + ]; + return ( +
+ {options.map((option, index) => ( +
+

{option.name}

+ {option.icon && {`${option.name}} +
+ ))} +
+ ); + }} + > +
+
+ Connected Img +
+ +
+
+

{accountName}

+

{credit} credits

+
+ + dropdown icon +
+
+
+ ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/Table/Table.module.scss b/src/components/Table/Table.module.scss index 3778ef1c4..21888fbff 100644 --- a/src/components/Table/Table.module.scss +++ b/src/components/Table/Table.module.scss @@ -120,5 +120,9 @@ padding-right: 10px; } } + &.no-data { + display: flex; + justify-content: center; + } } } diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index bc9d93549..a0222b590 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,9 +1,12 @@ +import cn from 'classnames/bind'; import { ReactComponent as SortDownIcon } from 'assets/icons/down_icon.svg'; import { ReactComponent as SortUpIcon } from 'assets/icons/up_icon.svg'; -import { compareNumber } from 'helper'; +import { compareNumber, divNumber } from 'helper'; import { ReactNode, useState } from 'react'; import styles from './Table.module.scss'; +const cx = cn.bind(styles); + export type HeaderDataType = { name: string; accessor: (data: T) => ReactNode | string | undefined; @@ -20,6 +23,7 @@ export type TableProps = { data: T[]; stylesColumn?: React.CSSProperties; handleClickRow?: (event: React.MouseEvent, record: T) => void; + noData?: ReactNode; }; export enum SortType { @@ -112,7 +116,8 @@ export const Table = ({ headers, data, handleClickRow, - stylesColumn + stylesColumn, + noData }: TableProps) => { const [sort, setSort] = useState>({ [defaultSorted]: SortType.DESC @@ -137,6 +142,7 @@ export const Table = ({ setSort(newSort); sortDataSource(data, newSort); }; + const isNoData = !data.length && noData; return ( @@ -164,21 +170,27 @@ export const Table = ({ })} - - {sortDataSource(data, sort).map((datum, index) => { - return ( - handleClickRow && handleClickRow(event, datum)}> - {Object.keys(headers).map((key, index) => { - const customStyle = getCustomStyleByColumnKey(headers, key); - return ( - - ); - })} - - ); - })} + + {isNoData + ? noData + : sortDataSource(data, sort).map((datum, index) => { + return ( + handleClickRow && handleClickRow(event, datum)} + > + {Object.keys(headers).map((key, index) => { + const customStyle = getCustomStyleByColumnKey(headers, key); + return ( + + ); + })} + + ); + })}
- {headers[key].accessor(datum)} -
+ {headers[key].accessor(datum)} +
); diff --git a/src/layouts/Menu.module.scss b/src/layouts/Menu.module.scss index 3a8cb16d1..2a90fd2d6 100644 --- a/src/layouts/Menu.module.scss +++ b/src/layouts/Menu.module.scss @@ -3,7 +3,7 @@ .menu { display: flex; - justify-content: space-between; + // justify-content: center; position: fixed; width: 100%; top: 0; @@ -168,6 +168,30 @@ } } +.wrapMenuRight { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; +} + +.menuMiddle { + display: flex; + align-items: center; + justify-content: center; + gap: var(--dimension-spacing-col-between-md, 8px); + padding: 16px; + + .menuMiddleText { + color: var(--colors-neutral-text-body, #b4b7bb); + font-family: 'IBM Plex Sans'; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + } +} + .menuLeft { display: flex; align-items: center; @@ -195,7 +219,7 @@ .menuRight { display: flex; align-items: center; - gap: 16px; + gap: 8px; font-weight: 500; padding-right: 20px; diff --git a/src/layouts/Menu.tsx b/src/layouts/Menu.tsx index adf1a2ff3..a6b19f9db 100644 --- a/src/layouts/Menu.tsx +++ b/src/layouts/Menu.tsx @@ -11,6 +11,8 @@ import React, { ReactNode, useContext, useEffect, useRef, useState } from 'react import { Link, useLocation } from 'react-router-dom'; import styles from './Menu.module.scss'; import Sidebar from './Sidebar'; +import TopBarIcon from 'assets/images/flicker-dot.svg'; +import { GithubConnect } from 'components/GithubConnect'; const Menu: React.FC = () => { const location = useLocation(); @@ -118,11 +120,24 @@ const Menu: React.FC = () => { -
-
- - - + +
+
+ top_bar_icon + +
+ Millions of EVM wallet users will soon be able to join the Oraichain ecosystem with ease! +
+
+ +
+ + +
+ + + +
diff --git a/src/layouts/Sidebar.tsx b/src/layouts/Sidebar.tsx index 2c20fb2c4..fd28b7272 100644 --- a/src/layouts/Sidebar.tsx +++ b/src/layouts/Sidebar.tsx @@ -2,6 +2,7 @@ import { ReactComponent as BuyCryptoIcon } from 'assets/icons/buy_crypto.svg'; import { ReactComponent as CoHavestIcon } from 'assets/icons/co_harvest.svg'; import { ReactComponent as GovernanceIcon } from 'assets/icons/governance.svg'; import { ReactComponent as GpuStakingIcon } from 'assets/icons/gpu_staking.svg'; +import { ReactComponent as GpuCreditIcon } from 'assets/icons/gpu_credit.svg'; import { ReactComponent as HomeBaseIcon } from 'assets/icons/homebase.svg'; import { ReactComponent as GitIcon } from 'assets/icons/ic_github.svg'; import { ReactComponent as DiscordIcon } from 'assets/icons/ic_discord.svg'; @@ -103,6 +104,7 @@ const Sidebar: React.FC<{}> = React.memo((props) => {
{renderLink('/homebase', 'Homebase', setLink, )} {renderLink('/gpu-staking', 'GPU Staking', setLink, )} + {renderLink('/gpu-credit', 'GPU Credit', setLink, )} {renderLink( 'https://scan.orai.io/validators', 'ORAI Staking', diff --git a/src/pages/GithubLogin/index.module.scss b/src/pages/GithubLogin/index.module.scss new file mode 100644 index 000000000..336bdb0c3 --- /dev/null +++ b/src/pages/GithubLogin/index.module.scss @@ -0,0 +1,54 @@ +.wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + margin-top: 100px; +} + +.redirect { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + + .desc { + text-align: center; + font-size: 30px; + } +} + +.loader-wrap { + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 6px 0; + + .loader { + width: 30px; + aspect-ratio: 2; + --_g: no-repeat radial-gradient(circle closest-side, #a19d9d 90%, #6b696900); + background: var(--_g) 0% 50%, var(--_g) 50% 50%, var(--_g) 100% 50%; + background-size: calc(100% / 3) 50%; + animation: l3 1s infinite linear; + } + @keyframes l3 { + 20% { + background-position: 0% 0%, 50% 50%, 100% 50%; + } + 40% { + background-position: 0% 100%, 50% 0%, 100% 50%; + } + 60% { + background-position: 0% 50%, 50% 100%, 100% 0%; + } + 80% { + background-position: 0% 50%, 50% 50%, 100% 100%; + } + } +} diff --git a/src/pages/GithubLogin/index.tsx b/src/pages/GithubLogin/index.tsx new file mode 100644 index 000000000..e12a5eed9 --- /dev/null +++ b/src/pages/GithubLogin/index.tsx @@ -0,0 +1,64 @@ +import React, { useEffect } from 'react'; +import classNames from 'classnames/bind'; +import { useDispatch } from 'react-redux'; +import { toast } from 'react-toastify'; +import { useLocation } from 'react-router-dom'; + +import axios from 'rest/request'; +import { setAccountName, setCredit, setTokens } from 'reducer/auth'; +import { getLatestCsrf } from 'utils/githubCode'; +import styles from './index.module.scss'; + +const cx = classNames.bind(styles); + +const GithubLogin: React.FC = () => { + const dispatch = useDispatch(); + const { search } = useLocation(); + + const urlParams = new URLSearchParams(search); + const code = urlParams.get('code'); + const latestCsrf = urlParams.get('state'); + + useEffect(() => { + const login = async () => { + const resp = await axios.post(`${process.env.REACT_APP_BASE_GPU_API_URL}/github-auth`, { code }); + + const { accessToken, refreshToken, creditRemain, username } = resp.data; + + dispatch(setTokens({ access: accessToken, refresh: refreshToken })); + dispatch(setCredit(creditRemain)); + dispatch(setAccountName(username)); + }; + + if (code && latestCsrf && latestCsrf === getLatestCsrf()) { + // TODO: refactor this for better UX + login().then(() => { + toast.success('Login success'); + setTimeout(() => { + window.history.back(); + }, 3); + }).catch(() => { + toast.error('Login failed'); + setTimeout(() => { + window.location.assign('/'); + }, 5); + }); + } else { + // TODO: print error message + console.log('not login'); + } + }, [code, latestCsrf]); + + return ( +
+
+
Logging in by Github
+
+
+
+
+
+ ); +}; + +export default GithubLogin; diff --git a/src/pages/GpuCredit/index.module.scss b/src/pages/GpuCredit/index.module.scss new file mode 100644 index 000000000..ebb3c7b24 --- /dev/null +++ b/src/pages/GpuCredit/index.module.scss @@ -0,0 +1,282 @@ +@import 'src/styles/mixins'; +@import 'src/styles/themes'; + +.container { + display: inline-flex; + // padding: 32px 0px; + flex-direction: column; + align-items: flex-start; + gap: 56px; + + .title { + color: var(--Colors-Neutral-Text-heading, #f7f7f7); + font-size: 24px; + font-weight: bold; + line-height: 28px; + } + + a.external { + color: var(--Colors-Primary-Text-action, #b999f3); + font-family: 'IBM Plex Sans'; + font-size: 16px; + font-weight: 500; + line-height: 20px; /* 125% */ + + @include mobile { + display: none; + } + + svg { + path { + fill: #b999f3; + } + + vertical-align: sub; + width: 20px; + height: 20px; + } + } + + .statistics { + display: flex; + gap: var(--Dimensions-40, 40px); + + align-self: stretch; + align-items: stretch; + + & > * { + flex: 1; + + display: flex; + flex-direction: column; + gap: 16px; + } + + .daily-credit-usage { + .chart { + padding-top: 20px; + + canvas { + max-height: 250px; + } + } + } + + .gpu-statistics { + .content { + flex-grow: 1; + + .basic-info { + // width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + & > * { + flex: 1; + } + + .header { + color: var(--Colors-Neutral-Text-placeholder, #83838a); + } + .value { + font-size: 1.5em; + } + } + + .gpu-detail { + display: flex; + flex-direction: column; + gap: 24px; + + & > * { + flex: 1; + } + + .gpu { + display: flex; + flex-direction: column; + gap: 12px; + + .usage { + display: flex; + justify-content: space-between; + + .text { + color: var(--Colors-Neutral-Text-placeholder, #83838a); + } + } + .progress { + height: var(--Dimensions-16, 16px); + border: var(--Colors-Neutral-Border-default, #383b40) 1px solid; + + .percent-value { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + border: var(--Colors-Neutral-Border-progress-bar, #83838a) 1px solid; + background-color: var(--Colors-Neutral-Surface-progess-bar, rgba(131, 131, 138, 0.5)); + } + } + } + .link { + line-height: 32px; + } + } + } + } + } + + .personal-info { + padding: 32px 24px 32px 24px; + border-radius: var(--Dimension-Corner-Radius-row, 8px); + background: linear-gradient(180deg, #1f1f20 0%, #141416 321px); + + display: flex; + flex-direction: column; + gap: 24px; + + .intro { + display: flex; + gap: 32px; + + & > .text { + width: 60%; + display: flex; + flex-direction: column; + gap: 32px; + + .content { + display: flex; + flex-direction: column; + gap: 24px; + + p { + color: var(--Colors-Neutral-Text-placeholder, #83838a); + } + + .link { + line-height: 32px; + } + } + } + .interaction { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 16px; + + .credit-balance { + background-image: url('../../assets/images/bg-gpu-credit-balance.png'); + background-repeat: no-repeat; + background-size: cover; + border-radius: 8px; + border: 1px solid var(--Colors-Neutral-Border-default, #383b40); + flex-grow: 1; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 24px; + + .info { + display: flex; + flex-direction: column; + align-items: center; + + .text { + font-size: 16px; + line-height: 24px; + } + .value { + font-size: 32px; + line-height: 130%; + color: var(--Colors-Neutral-Text-heading, #f7f7f7); + } + } + .list-buttons { + display: flex; + gap: 16px; + + button { + padding: 14px 24px; + + &:disabled { + opacity: 1; + color: var(--Colors-Neutral-Text-disable, #47474b); + + display: flex; + justify-content: center; + + svg { + path { + fill: var(--Colors-Neutral-Text-disable, #47474b); + } + + width: 20px; + height: 20px; + } + } + } + } + } + + .go-to-panel { + display: flex; + justify-content: space-between; + gap: 16px; + + a { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-radius: var(--Dimension-Corner-Radius-check-box, 4px); + border: 1px solid var(--Colors-Neutral-Border-light, #242627); + // width: 45%; + } + } + } + } + .credit-usage-history { + display: flex; + flex-direction: column; + gap: 16px; + + .title { + line-height: 24px; + } + .table { + border-radius: var(--Dimension-Corner-Radius-row, 8px); + border: 1px solid var(--Colors-Neutral-Border-default, #383b40); + padding-bottom: 24px; + + th { + color: var(--Colors-Neutral-Text-placeholder, #83838a); + font-family: 'IBM Plex Sans'; + font-size: 12px; + font-weight: 400; + line-height: 150%; /* 18px */ + letter-spacing: 0.012px; + } + + .no-data { + height: 222px; + border-bottom: none; + + td { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + } + } + } + } +} diff --git a/src/pages/GpuCredit/index.tsx b/src/pages/GpuCredit/index.tsx new file mode 100644 index 000000000..edaa43f50 --- /dev/null +++ b/src/pages/GpuCredit/index.tsx @@ -0,0 +1,362 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { toast } from 'react-toastify'; +import cn from 'classnames/bind'; +import { Bar } from 'react-chartjs-2'; +import { CategoryScale, BarElement } from 'chart.js'; +import Chart from 'chart.js/auto'; + +import Content from 'layouts/Content'; +import { ReactComponent as JumpIcon } from 'assets/icons/jump.svg'; +import { ReactComponent as GitHubIcon } from 'assets/icons/github.svg'; +import { ReactComponent as NoCreditUsageHistoryIcon } from 'assets/images/no-credit-usage-history.svg'; +import { ReactComponent as AddIcon } from 'assets/icons/Add.svg'; +import { ReactComponent as TimeIcon } from 'assets/icons/time.svg'; +import { ReactComponent as KeyIcon } from 'assets/icons/key.svg'; +import JupyterHubImg from 'assets/images/jupyterhub.png'; +import { Table, TableHeaderProps } from 'components/Table'; +import { Button } from 'components/Button'; +import { handleConnectGithub } from 'components/GithubConnect/helper'; +import { RootState } from 'store/configure'; +import axios, { axiosAuth } from 'rest/request'; +import styles from './index.module.scss'; + +const cx = cn.bind(styles); +Chart.register(CategoryScale, BarElement); +const baseApiUrl = process.env.REACT_APP_BASE_GPU_API_URL; + +const GpuCredit: React.FC<{}> = () => { + const [gpuStatistics, setGpuStatistics] = useState({ + totalCards: 12, + totalVRAM: 295, + totalUsage: 0 + }); + const [gpuStatus, setGpuStatus] = useState([]); + const [creditUsageHistoryData, setCreditUsageHistoryData] = useState([]); + const [dailyCreditUsage, setDailyCreditUsage] = useState([]); + + const tokens = useSelector((state: RootState) => state.auth.token); + const credit = useSelector((state: RootState) => state.auth.credit); + const loggedIn = !!tokens.access; + + const [chartData, setChartData] = useState({ + labels: dailyCreditUsage.map((data) => new Date(data.date).getDate()), + datasets: [ + { + // label: undefined, + data: dailyCreditUsage.map((data) => data.credit), + backgroundColor: ['#7332E7'], + borderColor: '#B999F3', + borderWidth: 2, + borderRadius: 4, + borderSkipped: false, + barThickness: 20 + } + ] + }); + + useEffect(() => { + if (loggedIn) { + const getData = async () => { + let promises; + + try { + promises = await Promise.all([ + axiosAuth.get(`${baseApiUrl}/credit-usage-history?filter=negative`, { + headers: { Authorization: `Bearer ${tokens.access}` } + }), + axiosAuth.get(`${baseApiUrl}/credit-usage-per-day`, { + headers: { Authorization: `Bearer ${tokens.access}` } + }) + ]); + } catch (e) { + console.error('Credit usage history:', e); + + toast.error('Failed to qery credit usage'); + } + + // TODO: refactor this, promises may get [undefined, undefined] due to interceptors + if (promises && promises[0] && promises[1]) { + const creditUsageHistory = promises[0].data.creditUsageHistory; + const totalCreditUsageEachDay = promises[1].data.totalCreditUsageEachDay; + + setCreditUsageHistoryData(creditUsageHistory); + setDailyCreditUsage(totalCreditUsageEachDay); + } + }; + getData(); + } else { + axios + .get(`${baseApiUrl}/credit-usage-per-day-of-server`) + .then(({ data }) => { + setDailyCreditUsage(data.totalCreditUsageEachDayByServer); + }) + .catch((e) => { + console.error('Credit usage per day:', e); + + // TODO: handle error + toast.error('Failed to fetch daily credit usage'); + }); + } + + axios + .get(`${baseApiUrl}/gpu-statistics`) + .then(({ data }) => { + const gpuStatisticsData = { + totalCards: data.numberOfCards, + totalVRAM: Math.round(data.totalVRAM), + totalUsage: data.totalUsage.toFixed(2).replace(/\.0+$/, '') + }; + setGpuStatistics(gpuStatisticsData); + + // TODO: set interval + // TODO: set real data + const gpuStatusData = data.hosts + .sort((host1, host2) => host2.vramUsage - host1.vramUsage) + .slice(0, 2) + .map((host) => ({ + name: `${host.name} ${host.gpuNumber}x ${host.gpuName}`, + capacity: Math.floor(host.totalVRAM), + currentUsage: Math.floor(host.vramUsage) + })); + setGpuStatus(gpuStatusData); + }) + .catch((e) => { + console.error('gpu-statistics:', e); + + // TODO: handle error + toast.error('Failed to fetch gpu statistics'); + }); + }, [tokens]); + + useEffect(() => { + setChartData({ + labels: dailyCreditUsage.map((data) => new Date(data.date).getDate()), + datasets: [ + { + // label: undefined, + data: dailyCreditUsage.map((data) => data.credit), + backgroundColor: ['#7332E7'], + borderColor: '#B999F3', + borderWidth: 2, + borderRadius: 4, + borderSkipped: false, + barThickness: 20 + } + ] + }); + }, [dailyCreditUsage]); + + const gpuStatusElements = gpuStatus.map((detail, idx) => { + const percentUsage = Math.floor((detail.currentUsage / detail.capacity) * 100); + return ( +
+
+
{detail.name} Usage:
+
+ {Math.floor(detail.currentUsage)}/{detail.capacity} GB +
+
+
+
+ {percentUsage}% +
+
+
+ ); + }); + + const creditUsageHistoryHeaders: TableHeaderProps = { + timestamp: { + name: 'TIMESTAMP', + accessor: (data) => new Date(data.timestamp).toISOString().replace(/T.+$/, ''), + width: '30%', + align: 'left', + padding: `0px 0px 0px 24px` + }, + action: { + name: 'ACTION', + accessor: (data) => data.action, + width: '40%', + align: 'left' + }, + creditUsage: { + name: 'CREDIT USAGE', + accessor: (data) => data.credit, + width: '30%', + align: 'left' + } + }; + + return ( + +
+
+
+

Total Credit Usage Each Day

+
+ +
+
+
+

GPU Statistics

+
+
+
+
Total GPU Cards:
+
{gpuStatistics.totalCards}
+
+
+
Total VRAM:
+
{gpuStatistics.totalVRAM} GB
+
+
+
Total Usage:
+
{gpuStatistics.totalUsage}%
+
+
+
+
+ {gpuStatusElements} + + View all GPU Status  {} + +
+
+
+
+
+
+
+

Decentralized GPU Infra

+
+

+ Decentralized GPU infra is crucial for the advancement of AI x Blockchain. DApps builders that are + eager to scale AI in Blockchain environment can apply for GPU Credits offered by Oraichain Labs and + expand their project capabilities. +
+ GPU Credits Offering supercharges AI businesses and empowers them to thrive on blockchain environment. + Decentralized GPU infrastructure is crucial for the advancement of AI x Blockchain. +
+ With Decentralized GPU Infra, projects can:
+ - Optimize cost +
- Reduce common infrastructure risks of relying on a single provider. +

+ + Learn more  {} + +
+
+
+
+
+
Your Credits
+
{tokens.access ? credit : 0}
+
+
+ {loggedIn ? ( + <> + + + + ) : ( + + )} +
+
+ +
+
+
+

Credit Usage History

+
+ + + + } + /> + + + + + + ); +}; + +export default GpuCredit; diff --git a/src/reducer/auth.ts b/src/reducer/auth.ts new file mode 100644 index 000000000..5baddaabb --- /dev/null +++ b/src/reducer/auth.ts @@ -0,0 +1,40 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; +import { AuthState } from './type'; + +const initialState: AuthState = { + token: { + access: '', + refresh: '' + }, + accountName: '', + credit: 0 +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setTokens: (state, action: PayloadAction) => { + state.token = action.payload; + }, + setAccountName: (state, action: PayloadAction) => { + state.accountName = action.payload; + }, + setCredit: (state, action: PayloadAction) => { + state.credit = action.payload; + }, + reset: (state) => { + state.token = { + access: '', + refresh: '' + }; + state.accountName = ''; + state.credit = 0; + } + } +}); + +export const { setTokens, setAccountName, setCredit, reset } = authSlice.actions; + +export default authSlice.reducer; diff --git a/src/reducer/type.ts b/src/reducer/type.ts index 8d9f13ecc..7166f064f 100644 --- a/src/reducer/type.ts +++ b/src/reducer/type.ts @@ -196,6 +196,15 @@ export interface OrderResponseContract { orders: OrderDetailFromContract[]; } +export interface AuthState { + token: { + access: string; + refresh: string; + }; + accountName: string; + credit: number; +} + export enum OrderStatus { OPEN = 'OPEN', FUL_FILLED = 'FUL_FILLED', diff --git a/src/rest/request.ts b/src/rest/request.ts index 10708ca06..ccb5140d7 100644 --- a/src/rest/request.ts +++ b/src/rest/request.ts @@ -1,8 +1,10 @@ import Axios from 'axios'; +import { store } from 'store/configure'; +import { setTokens, reset } from 'reducer/auth'; import { throttleAdapterEnhancer, retryAdapterEnhancer } from 'axios-extensions'; import { AXIOS_TIMEOUT, AXIOS_THROTTLE_THRESHOLD } from '@oraichain/oraidex-common'; -const axios = Axios.create({ +export default Axios.create({ timeout: AXIOS_TIMEOUT, retryTimes: 3, // cache will be enabled by default in 2 seconds @@ -14,4 +16,48 @@ const axios = Axios.create({ baseURL: process.env.REACT_APP_BASE_API_URL }); -export default axios; +export const axiosAuth = Axios.create({ + timeout: AXIOS_TIMEOUT, + adapter: retryAdapterEnhancer( + throttleAdapterEnhancer(Axios.defaults.adapter!, { + threshold: AXIOS_THROTTLE_THRESHOLD + }) + ), + baseURL: process.env.REACT_APP_BASE_GPU_API_URL +}); + +let refreshTokenPromise; + +const getRefreshToken = () => { + return Axios.post(`${process.env.REACT_APP_BASE_GPU_API_URL}/refresh-token`, { + refreshToken: store.getState().auth.token.refresh + }); +}; + +axiosAuth.interceptors.response.use( + (res) => res, + (error) => { + if (error.response?.status === 401) { + if (!refreshTokenPromise) { + // check for an existing in-progress request + // if nothing is in-progress, start a new refresh token request + refreshTokenPromise = getRefreshToken().then((resp) => { + refreshTokenPromise = null; // clear state + return resp.data; // resolve with the new token + }); + } + return refreshTokenPromise + .then((tokens) => { + const newTokens = { + access: tokens.accessToken, + refresh: tokens.refreshToken + }; + store.dispatch(setTokens(newTokens)); + }) + .catch(() => { + store.dispatch(reset()); + }); + } + return Promise.reject(error); + } +); diff --git a/src/routes.tsx b/src/routes.tsx index 1dae340b4..a6cc6d435 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -3,9 +3,10 @@ import Loader from 'components/Loader'; import NotFound from 'pages/NotFound'; import { Suspense } from 'react'; import { Route, Routes } from 'react-router-dom'; - +import GpuCredit from 'pages/GpuCredit'; import GpuStaking from 'pages/GpuStaking'; import UniversalSwap from 'pages/UniversalSwap/index'; +import GithubLogin from 'pages/GithubLogin'; export default () => ( ( } /> } /> } /> + } /> + } /> } /> diff --git a/src/store/configure.ts b/src/store/configure.ts index 824ea36b8..963f2fdb7 100644 --- a/src/store/configure.ts +++ b/src/store/configure.ts @@ -6,6 +6,7 @@ import tradingReducer from '../reducer/tradingSlice'; import walletReducer from '../reducer/wallet'; import poolChartReducer from '../reducer/poolChartSlice'; import AddressBookReducer from '../reducer/addressBook'; +import AuthReducer from '../reducer/auth'; import storage from 'redux-persist/lib/storage'; import { persistReducer, persistStore } from 'redux-persist'; import { PERSIST_CONFIG_KEY } from './constants'; @@ -22,7 +23,8 @@ const rootReducer = combineReducers({ trading: tradingReducer, wallet: walletReducer, poolChart: poolChartReducer, - addressBook: AddressBookReducer + addressBook: AddressBookReducer, + auth: AuthReducer }); const persistedReducer = persistReducer(rootPersistConfig, rootReducer); diff --git a/src/utils/githubCode.ts b/src/utils/githubCode.ts new file mode 100644 index 000000000..4aaae771d --- /dev/null +++ b/src/utils/githubCode.ts @@ -0,0 +1,13 @@ +const LATEST_CSRF_TOKEN = 'latest_csrf_token'; + +export const setLatestCsrf = (csrf: string) => { + // if (localStorage.getItem(LATEST_CSRF_TOKEN)) { + // return; + // } + // console.log('SET NEW CSRF', csrf); + localStorage.setItem(LATEST_CSRF_TOKEN, csrf); +}; + +export const getLatestCsrf = () => { + return localStorage.getItem(LATEST_CSRF_TOKEN); +}; diff --git a/tsconfig.json b/tsconfig.json index dc13a8ee9..47843fcab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,4 +30,4 @@ "exclude": [ "node_modules", ] -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 317dd8837..c9aca70cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2757,6 +2757,11 @@ dependencies: hash-sum "^2.0.0" +"@kurkle/color@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f" + integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw== + "@leapwallet/cosmos-snap-provider@0.1.25": version "0.1.25" resolved "https://registry.yarnpkg.com/@leapwallet/cosmos-snap-provider/-/cosmos-snap-provider-0.1.25.tgz#f256cd4c7ef89aa9209ed8dbaf16487db24bde10" @@ -3730,7 +3735,7 @@ sha3 "^2.1.4" shx "^0.3.4" -"@tippyjs/react@^4.2.0": +"@tippyjs/react@^4.2.6": version "4.2.6" resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71" integrity sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw== @@ -6169,6 +6174,13 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chart.js@^4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.4.tgz#b682d2e7249f7a0cbb1b1d31c840266ae9db64b7" + integrity sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA== + dependencies: + "@kurkle/color" "^0.3.0" + check-types@^11.2.3: version "11.2.3" resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.2.3.tgz#1ffdf68faae4e941fce252840b1787b8edc93b71" @@ -7384,6 +7396,11 @@ dotenv@^16.3.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.1.tgz#1d9931f1d3e5d2959350d1250efab299561f7f11" integrity sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ== +dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dotenv@^8.2.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -13792,6 +13809,11 @@ react-app-rewired@^2.2.1: dependencies: semver "^5.6.0" +react-chartjs-2@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz#43c1e3549071c00a1a083ecbd26c1ad34d385f5d" + integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA== + react-dev-utils@^12.0.1: version "12.0.1" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73"
+ + No records found +