diff --git a/frontend/providers/template/public/locales/en/common.json b/frontend/providers/template/public/locales/en/common.json index 3279a7764141..70231a256e68 100644 --- a/frontend/providers/template/public/locales/en/common.json +++ b/frontend/providers/template/public/locales/en/common.json @@ -7,6 +7,13 @@ "Advanced Configuration": "Advanced", "Age": "Uptime", "AnticipatedPrice": "Estimated Cost", + "app": { + "Resource Quota": "Resource Quota", + "The applied CPU exceeds the quota": "Requested CPU exceeds your quota. Please contact admin.", + "The applied GPU exceeds the quota": "Requested GPU exceeds your quota. Please contact admin.", + "The applied memory exceeds the quota": "Requested storage exceeds your quota. Please contact admin.", + "The applied storage exceeds the quota": "The applied storage exceeds the quota" + }, "App Name": "App Name", "Application Deployment": "App Deployment", "Application List": " App List", @@ -17,6 +24,7 @@ "Auto scaling": "Scaling", "Basic Information": "Basic", "Button Effect": "Button Appearance", + "cpu": "CPU", "CPU": "CPU", "CPU target is the CPU utilization rate of any container": "CPU target represents the CPU utilization rate of any container", "CPU target value": "CPU Target", @@ -25,6 +33,10 @@ "Command": "Command", "Command parameters": "Command parameters", "Component": "Component", + "common": { + "Surplus": "Surplus", + "Used": "Used" + }, "Config Form": "Form", "ConfigMap Path Conflict": "ConfigMap Path Conflict", "Configuration File": "Configmap", @@ -67,6 +79,7 @@ "File Value can not empty": "File content is required", "Filename can not empty": "File name is required", "Fixed instance": "Fixed", + "gpu": "GPU", "Heading to sealos soon": "Redirecting to Sealos shortly", "Home Page": "Project Home", "Html Part": "HTML Snippet", @@ -89,6 +102,7 @@ "Log": "Log", "Markdown Part": "Markdown Snippet", "Max Storage Value": "Maximum storage: ", + "memory": "Memory", "Memory": "Memory", "Memory target value": "Memory Target", "Min Storage Value": "Minimum storage: ", @@ -96,6 +110,7 @@ "Name": "Name", "Network Configuration": "Network", "Next Execution Time": "Next Scheduled", + "Port": "Port", "No Applications": "No Apps Available", "None": "None", "Not Configured": "Not Configured", @@ -154,6 +169,7 @@ "Stateful": "Stateful", "Stateless": "Stateless", "Status": "Status", + "storage": "Storage", "Storage": "Storage", "Storage Range": "Storage Range", "Storage Value can not empty": "Storage capacity is required", @@ -165,6 +181,7 @@ "Templates": "Template Marketplace", "Terminal": "Terminal", "There is no resource of this type": "No resources of this type available", + "Total": "Total", "TotalPrice": "Total Price", "Type": "Type", "Unload": "Uninstall", @@ -213,5 +230,6 @@ "success": "success", "target_value": "Target Value", "users installed the app": "{{count}} users have installed this app", - "websocket": "WebSocket" + "websocket": "WebSocket", + "total_price_tip": "The estimated cost does not include port fees and traffic fees, and is subject to actual usage." } \ No newline at end of file diff --git a/frontend/providers/template/public/locales/zh/common.json b/frontend/providers/template/public/locales/zh/common.json index 35165f04466a..9cb060e45ebf 100644 --- a/frontend/providers/template/public/locales/zh/common.json +++ b/frontend/providers/template/public/locales/zh/common.json @@ -7,6 +7,13 @@ "Advanced Configuration": "高级配置", "Age": "启动时长", "AnticipatedPrice": "预估价格", + "app": { + "Resource Quota": "资源配额", + "The applied CPU exceeds the quota": "申请的 CPU 超出配额限制,请联系管理员", + "The applied GPU exceeds the quota": "申请的 GPU 超出配额限制,请联系管理员", + "The applied memory exceeds the quota": "申请的 '内存' 超出配额限制,请联系管理员", + "The applied storage exceeds the quota": "申请的 '存储' 超出配额限制,请联系管理员" + }, "App Name": "应用名称", "Application Deployment": "应用部署", "Application List": " 应用列表", @@ -17,6 +24,7 @@ "Auto scaling": "弹性伸缩", "Basic Information": "基本信息", "Button Effect": "按钮效果", + "cpu": "CPU", "CPU": "CPU", "CPU target is the CPU utilization rate of any container": "CPU 目标值为任一容器的 CPU 利用率", "CPU target value": "CPU 目标值", @@ -25,6 +33,10 @@ "Command": "启动命令", "Command parameters": "命令参数", "Component": "组件", + "common": { + "Surplus": "剩余", + "Used": "已用" + }, "Config Form": "配置表单", "ConfigMap Path Conflict": "配置文件路径冲突", "Configuration File": "配置文件", @@ -89,6 +101,7 @@ "Log": "日志", "Markdown Part": "Markdown 片段", "Max Storage Value": "容量最大为", + "memory": "内存", "Memory": "内存", "Memory target value": "内存目标值", "Min Storage Value": "容量最小为", @@ -96,6 +109,7 @@ "Name": "名字", "Network Configuration": "网络配置", "Next Execution Time": "下次执行时间", + "Port": "端口", "No Applications": "暂无应用", "None": "暂无", "Not Configured": "未配置", @@ -155,6 +169,7 @@ "Stateless": "无状态", "Status": "状态", "Storage": "存储卷", + "storage": "存储卷", "Storage Range": "容量范围", "Storage Value can not empty": "容量不能为空", "Storage path can not empty": "挂载路径不能为空", @@ -165,6 +180,7 @@ "Templates": "模板市场", "Terminal": "终端", "There is no resource of this type": "没有此类型的资源", + "Total": "总计", "TotalPrice": "总价", "Type": "类型", "Unload": "卸载", @@ -196,6 +212,7 @@ "file": "文件", "file value": "文件值", "filename": "文件名", + "gpu": "GPU", "grpcs": "gRPCS", "https": "HTTPS", "jump_message": "该应用不允许单独使用,点击确认前往 Sealos Desktop 使用。", @@ -213,5 +230,6 @@ "success": "成功", "target_value": "目标值", "users installed the app": "已有 {{count}} 名用户安装该应用", - "websocket": "WebSocket" + "websocket": "WebSocket", + "total_price_tip": "预估费用不包括端口费用和流量费用,以实际使用为准" } \ No newline at end of file diff --git a/frontend/providers/template/src/api/platform.ts b/frontend/providers/template/src/api/platform.ts index c4eac5711906..18dabe3e0506 100644 --- a/frontend/providers/template/src/api/platform.ts +++ b/frontend/providers/template/src/api/platform.ts @@ -1,6 +1,7 @@ import { EnvResponse } from '@/types/index'; import { GET } from '@/services/request'; import { SystemConfigType, TemplateType } from '@/types/app'; +import type { UserQuotaItemType, userPriceType } from '@/types/user'; export const updateRepo = () => GET('/api/updateRepo'); @@ -12,3 +13,11 @@ export const getPlatformEnv = () => GET('/api/platform/getEnv'); export const getSystemConfig = () => { return GET('/api/platform/getSystemConfig'); }; + +export const getUserQuota = () => + GET<{ + balance: number; + quota: UserQuotaItemType[]; + }>('/api/platform/getQuota'); + +export const getResourcePrice = () => GET('/api/platform/resourcePrice'); diff --git a/frontend/providers/template/src/components/Icon/icons/help.svg b/frontend/providers/template/src/components/Icon/icons/help.svg new file mode 100644 index 000000000000..29dad6392853 --- /dev/null +++ b/frontend/providers/template/src/components/Icon/icons/help.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/providers/template/src/components/Icon/index.tsx b/frontend/providers/template/src/components/Icon/index.tsx index 61240861fadf..096ef74030ec 100644 --- a/frontend/providers/template/src/components/Icon/index.tsx +++ b/frontend/providers/template/src/components/Icon/index.tsx @@ -37,7 +37,8 @@ const map = { empty: require('./icons/empty.svg').default, dev: require('./icons/dev.svg').default, eyeShow: require('./icons/eyeShow.svg').default, - tool: require('./icons/tool.svg').default + tool: require('./icons/tool.svg').default, + help: require('./icons/help.svg').default }; const MyIcon = ({ diff --git a/frontend/providers/template/src/pages/_app.tsx b/frontend/providers/template/src/pages/_app.tsx index 46e25184041e..908e37d9c1fe 100644 --- a/frontend/providers/template/src/pages/_app.tsx +++ b/frontend/providers/template/src/pages/_app.tsx @@ -15,6 +15,7 @@ import { EVENT_NAME } from 'sealos-desktop-sdk'; import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app'; import { useSystemConfigStore } from '@/store/config'; import useSessionStore from '@/store/session'; +import { useUserStore } from '@/store/user'; import '@/styles/reset.scss'; import 'nprogress/nprogress.css'; @@ -41,9 +42,11 @@ const App = ({ Component, pageProps }: AppProps) => { const { setScreenWidth, setLastRoute } = useGlobalStore(); const { initSystemConfig, initSystemEnvs } = useSystemConfigStore(); const [refresh, setRefresh] = useState(false); + const { loadUserSourcePrice } = useUserStore(); useEffect(() => { initSystemConfig(); + loadUserSourcePrice(); }, []); useEffect(() => { diff --git a/frontend/providers/template/src/pages/api/platform/getQuota.ts b/frontend/providers/template/src/pages/api/platform/getQuota.ts new file mode 100644 index 000000000000..0fab25081db9 --- /dev/null +++ b/frontend/providers/template/src/pages/api/platform/getQuota.ts @@ -0,0 +1,23 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { authSession } from '@/services/backend/auth'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + // source price + const { getUserQuota } = await getK8s({ + kubeconfig: await authSession(req.headers) + }); + + const quota = await getUserQuota(); + + jsonRes(res, { + data: { + quota + } + }); + } catch (error) { + jsonRes(res, { code: 500, message: 'get price error' }); + } +} diff --git a/frontend/providers/template/src/pages/api/platform/resourcePrice.ts b/frontend/providers/template/src/pages/api/platform/resourcePrice.ts new file mode 100644 index 000000000000..e757b533e990 --- /dev/null +++ b/frontend/providers/template/src/pages/api/platform/resourcePrice.ts @@ -0,0 +1,75 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { POST } from '@/services/request'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { authSession } from '@/services/backend/auth'; +import type { userPriceType } from '@/types/user'; + +type properties = { + properties: property[]; +}; + +type property = { + name: string; + unit_price: number; + unit: string; +}; + +export function transformProperties(data: properties): userPriceType { + const userPrice: userPriceType = { + cpu: 0, + memory: 0, + storage: 0, + nodeports: 0 + }; + + data.properties.forEach((property: property) => { + switch (property.name) { + case 'cpu': + userPrice.cpu = property.unit_price; + break; + case 'memory': + userPrice.memory = property.unit_price; + break; + case 'storage': + userPrice.storage = property.unit_price; + break; + case 'services.nodeports': + userPrice.nodeports = property.unit_price; + break; + } + }); + + return userPrice; +} + +const getResourcePrice = async () => { + const res = await fetch( + `https://account-api.${process.env.SEALOS_CLOUD_DOMAIN}/account/v1alpha1/properties`, + { + method: 'POST' + } + ); + const data = await res.json(); + return transformProperties(data.data as properties); +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + // source price + const { applyYamlList, k8sCustomObjects, k8sCore, namespace } = await getK8s({ + kubeconfig: await authSession(req.headers) + }); + + const data = await getResourcePrice(); + + jsonRes(res, { + code: 200, + data: data + }); + } catch (error) { + console.log('get resoure price error: ', error); + + jsonRes(res, { code: 500, message: 'get price error' }); + } +} diff --git a/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx b/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx new file mode 100644 index 000000000000..535e288886c7 --- /dev/null +++ b/frontend/providers/template/src/pages/deploy/components/PriceBox.tsx @@ -0,0 +1,163 @@ +import React, { useMemo } from 'react'; +import { Box, Center, Flex, useTheme } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { Text, Icon } from '@chakra-ui/react'; +import { useUserStore } from '@/store/user'; +import MyIcon from '@/components/Icon'; +import { MyTooltip } from '@sealos/ui'; +import { type ResourceUsage } from '@/utils/usage'; + +function Currencysymbol({ + type = 'shellCoin', + ...props +}: { + type?: 'shellCoin' | 'cny' | 'usd'; +} & Pick[0], 'w' | 'h' | 'color'>) { + return type === 'shellCoin' ? ( + + + + + + + + + + + ) : type === 'cny' ? ( + + ) : ( + $ + ); +} + +const scale = 1000000; + +const PriceBox = ({ cpu, memory, storage, nodeport }: ResourceUsage) => { + const { t } = useTranslation(); + const { userSourcePrice } = useUserStore(); + const theme = useTheme(); + + const priceList = useMemo(() => { + if (!userSourcePrice) return []; + + const cpuPMin = +((userSourcePrice.cpu * cpu.min * 24) / scale).toFixed(2); + const cpuPMax = +((userSourcePrice.cpu * cpu.max * 24) / scale).toFixed(2); + + const memoryPMin = +((userSourcePrice.memory * memory.min * 24) / scale).toFixed(2); + const memoryPMax = +((userSourcePrice.memory * memory.max * 24) / scale).toFixed(2); + + const storagePMin = +((userSourcePrice.storage * storage.min * 24) / scale).toFixed(2); + const storagePMax = +((userSourcePrice.storage * storage.max * 24) / scale).toFixed(2); + + const nodePortP = +((userSourcePrice.nodeports * nodeport * 24) / 1000).toFixed(2); + + // const gpuPMin = (() => { + // if (!gpu || !gpu[0]) return 0; + // const item = userSourcePrice?.gpu?.find((item) => item.type === gpu[0].type); + // if (!item) return 0; + // return +(item.price * gpu[0].amount * 24).toFixed(2); + // })(); + + // const gpuPMax = (() => { + // if (!gpu || !gpu[1]) return 0; + // const item = userSourcePrice?.gpu?.find((item) => item.type === gpu[1].type); + // if (!item) return 0; + // return +(item.price * gpu[1].amount * 24).toFixed(2); + // })(); + + const totalPMin = +(cpuPMin + memoryPMin + storagePMin + nodePortP).toFixed(2); + const totalPMax = +(cpuPMax + memoryPMax + storagePMax + nodePortP).toFixed(2); + + const podScale = (min: number, max: number) => { + return ( + + + {min === max ? `${min}` : `${min} ~ ${max}`} + + ); + }; + + return [ + { + label: 'CPU', + color: '#13C4B9', + value: podScale(cpuPMin, cpuPMax) + }, + { label: 'Memory', color: '#219BF4', value: podScale(memoryPMin, memoryPMax) }, + { label: 'Storage', color: '#8774EE', value: podScale(storagePMin, storagePMax) }, + { label: 'Port', color: '#C172E7', value: podScale(nodePortP, nodePortP) }, + // ...(userSourcePrice?.gpu + // ? [{ label: 'GPU', color: '#89CD11', value: podScale(gpuPMin, gpuPMax) }] + // : []), + { label: 'TotalPrice', color: '#111824', value: podScale(totalPMin, totalPMax) } + ]; + }, [cpu, memory, storage, userSourcePrice]); + + return ( + + + + {t('AnticipatedPrice')} + + ({t('Perday')}) + + + {priceList.map((item, index) => ( + + + + {t(item.label)} + {index === priceList.length - 1 && ( + +
+ +
+
+ )} + : +
+ {item.value} +
+ ))} +
+
+ ); +}; + +export default PriceBox; diff --git a/frontend/providers/template/src/pages/deploy/components/QuotaBox.tsx b/frontend/providers/template/src/pages/deploy/components/QuotaBox.tsx new file mode 100644 index 000000000000..6703e86050e5 --- /dev/null +++ b/frontend/providers/template/src/pages/deploy/components/QuotaBox.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { Box, Flex, useTheme, Progress, css, Text } from '@chakra-ui/react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'next-i18next'; +import { MyTooltip } from '@sealos/ui'; + +import { useUserStore } from '@/store/user'; + +const sourceMap = { + cpu: { + color: '#33BABB', + unit: 'Core' + }, + memory: { + color: '#36ADEF', + unit: 'Gi' + }, + storage: { + color: '#8172D8', + unit: 'GB' + }, + gpu: { + color: '#89CD11', + unit: 'Card' + } +}; + +const QuotaBox = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { userQuota, loadUserQuota } = useUserStore(); + useQuery(['getUserQuota'], loadUserQuota); + + const quotaList = useMemo(() => { + if (!userQuota) return []; + return userQuota + .map((item) => ({ + ...item, + tip: `${t('Total')}: ${`${item.limit} ${sourceMap[item.type]?.unit}`} +${t('common.Used')}: ${`${item.used} ${sourceMap[item.type]?.unit}`} +${t('common.Surplus')}: ${`${item.limit - item.used} ${sourceMap[item.type]?.unit}`} +`, + color: sourceMap[item.type]?.color + })) + .filter((item) => item.limit > 0); + }, [userQuota, t]); + + return userQuota.length === 0 ? null : ( + + + {t('app.Resource Quota')} + + + {quotaList.map((item) => ( + + + + {t(item.type)} + + div': { + bg: item.color + } + })} + /> + + + ))} + + + ); +}; + +export default QuotaBox; diff --git a/frontend/providers/template/src/pages/deploy/index.tsx b/frontend/providers/template/src/pages/deploy/index.tsx index fe879cf7a5a8..1400e6b59ee3 100644 --- a/frontend/providers/template/src/pages/deploy/index.tsx +++ b/frontend/providers/template/src/pages/deploy/index.tsx @@ -12,7 +12,7 @@ import { ApplicationType, TemplateSourceType } from '@/types/app'; import { serviceSideProps } from '@/utils/i18n'; import { generateYamlList, parseTemplateString } from '@/utils/json-yaml'; import { compareFirstLanguages, deepSearch, useCopyData } from '@/utils/tools'; -import { Box, Flex, Icon, Text } from '@chakra-ui/react'; +import { Box, Flex, Icon, Text, Grid, GridItem } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; import { useTranslation } from 'next-i18next'; @@ -24,6 +24,10 @@ import Form from './components/Form'; import ReadMe from './components/ReadMe'; import { getTemplateInputDefaultValues, getTemplateValues } from '@/utils/template'; import Head from 'next/head'; +import QuotaBox from './components/QuotaBox'; +import PriceBox from './components/PriceBox'; +import { useUserStore } from '@/store/user'; +import { getResourceUsage } from '@/utils/usage'; const ErrorModal = dynamic(() => import('./components/ErrorModal')); const Header = dynamic(() => import('./components/Header'), { ssr: false }); @@ -52,12 +56,21 @@ export default function EditApp({ const { screenWidth } = useGlobalStore(); const { setCached, cached, insideCloud, deleteCached, setInsideCloud } = useCachedStore(); const { setAppType } = useSearchStore(); + const { userSourcePrice, checkQuotaAllow, loadUserQuota } = useUserStore(); + useEffect(() => { + loadUserQuota(); + }, []); const detailName = useMemo( () => templateSource?.source?.defaults?.app_name?.value || '', [templateSource] ); + const usage = useMemo(() => { + const usage = getResourceUsage(yamlList.map((item) => item.value)); + return usage; + }, [yamlList]); + const { data: platformEnvs } = useQuery(['getPlatformEnvs'], getPlatformEnv, { staleTime: 5 * 60 * 1000 }); @@ -137,6 +150,20 @@ export default function EditApp({ }, [formHook, formOnchangeDebounce]); const submitSuccess = async () => { + const quoteCheckRes = checkQuotaAllow({ + cpu: usage.cpu.max, + memory: usage.memory.max, + storage: usage.storage.max + }); + if (quoteCheckRes) { + return toast({ + status: 'warning', + title: t(quoteCheckRes), + duration: 5000, + isClosable: true + }); + } + console.log('quoteCheckRes', quoteCheckRes); setIsLoading(true); try { if (!insideCloud) { @@ -351,12 +378,26 @@ export default function EditApp({ applyCb={() => formHook.handleSubmit(openConfirm(submitSuccess), submitError)()} /> -
+ {/* + + */} + + + + + + {userSourcePrice && ( + + + + )} + + {/* */} diff --git a/frontend/providers/template/src/services/backend/kubernetes.ts b/frontend/providers/template/src/services/backend/kubernetes.ts index 42517d8a84a6..4d151eb014bd 100644 --- a/frontend/providers/template/src/services/backend/kubernetes.ts +++ b/frontend/providers/template/src/services/backend/kubernetes.ts @@ -1,5 +1,7 @@ import * as k8s from '@kubernetes/client-node'; import * as JsYaml from 'js-yaml'; +import { memoryFormatToMi, cpuFormatToM } from '@/utils/tools'; +import { UserQuotaItemType } from '@/types/user'; // Load default kc export function K8sApiDefault(): k8s.KubeConfig { @@ -200,6 +202,40 @@ export function GetUserDefaultNameSpace(user: string): string { return 'ns-' + user; } +export async function getUserQuota( + kc: k8s.KubeConfig, + namespace: string +): Promise { + const k8sApi = kc.makeApiClient(k8s.CoreV1Api); + + const { + body: { status } + } = await k8sApi.readNamespacedResourceQuota(`quota-${namespace}`, namespace); + + return [ + { + type: 'cpu', + limit: cpuFormatToM(status?.hard?.['limits.cpu'] || '') / 1000, + used: cpuFormatToM(status?.used?.['limits.cpu'] || '') / 1000 + }, + { + type: 'memory', + limit: memoryFormatToMi(status?.hard?.['limits.memory'] || '') / 1024, + used: memoryFormatToMi(status?.used?.['limits.memory'] || '') / 1024 + }, + { + type: 'storage', + limit: memoryFormatToMi(status?.hard?.['requests.storage'] || '') / 1024, + used: memoryFormatToMi(status?.used?.['requests.storage'] || '') / 1024 + }, + { + type: 'gpu', + limit: Number(status?.hard?.['requests.nvidia.com/gpu'] || 0), + used: Number(status?.used?.['requests.nvidia.com/gpu'] || 0) + } + ]; +} + export async function getK8s({ kubeconfig }: { kubeconfig: string }) { const kc = K8sApi(kubeconfig); const kube_user = kc.getCurrentUser(); @@ -248,6 +284,7 @@ export async function getK8s({ kubeconfig }: { kubeconfig: string }) { k8sApiextensions: kc.makeApiClient(k8s.ApiextensionsV1Api), kube_user, namespace, - applyYamlList + applyYamlList, + getUserQuota: () => getUserQuota(kc, namespace) }); } diff --git a/frontend/providers/template/src/store/user.ts b/frontend/providers/template/src/store/user.ts new file mode 100644 index 000000000000..4a5708dc6ab4 --- /dev/null +++ b/frontend/providers/template/src/store/user.ts @@ -0,0 +1,91 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import { UserQuotaItemType } from '@/types/user'; +import { getUserQuota, getResourcePrice } from '@/api/platform'; +import type { userPriceType } from '@/types/user'; +import { CheckQuotaType } from '@/types/app'; + +type State = { + balance: number; + userQuota: UserQuotaItemType[]; + loadUserQuota: () => Promise; + userSourcePrice: userPriceType | undefined; + loadUserSourcePrice: () => Promise; + checkQuotaAllow: (request: CheckQuotaType, usedData?: CheckQuotaType) => string; +}; + +let retryGetPrice = 3; + +export const useUserStore = create()( + devtools( + immer((set, get) => ({ + userSourcePrice: undefined, + async loadUserSourcePrice() { + try { + const res = await getResourcePrice(); + set((state) => { + state.userSourcePrice = res; + }); + // console.log(res); + } catch (err) { + // retry fetch + retryGetPrice--; + if (retryGetPrice >= 0) { + setTimeout(() => { + get().loadUserSourcePrice(); + }, 1000); + } + } + return null; + }, + balance: 5, + userQuota: [], + loadUserQuota: async () => { + const response = await getUserQuota(); + set((state) => { + state.userQuota = response.quota; + state.balance = response.balance; + }); + return null; + }, + checkQuotaAllow: ( + { cpu, memory, storage, gpu }: CheckQuotaType, + usedData?: CheckQuotaType + ) => { + const quote = get().userQuota; + + const request = { + cpu: cpu / 1000, + memory: memory / 1024, + gpu: gpu?.type ? gpu.amount : 0, + storage: storage + }; + + if (usedData) { + const { cpu, memory, gpu, storage } = usedData; + + request.cpu -= cpu / 1000; + request.memory -= memory / 1024; + request.gpu -= gpu?.type ? gpu.amount : 0; + request.storage -= storage; + } + + const overLimitTip = { + cpu: 'app.The applied CPU exceeds the quota', + memory: 'app.The applied memory exceeds the quota', + gpu: 'app.The applied GPU exceeds the quota', + storage: 'app.The applied storage exceeds the quota' + }; + + const exceedQuota = quote.find((item) => { + if (item.used + request[item.type] > item.limit) { + return true; + } + }); + + return exceedQuota?.type ? overLimitTip[exceedQuota.type] : ''; + } + })) + ) +); diff --git a/frontend/providers/template/src/types/app.ts b/frontend/providers/template/src/types/app.ts index cfb011e077dc..0df552e2887f 100644 --- a/frontend/providers/template/src/types/app.ts +++ b/frontend/providers/template/src/types/app.ts @@ -178,3 +178,13 @@ export type SystemConfigType = { showCarousel: boolean; slideData: SlideDataType[]; }; + +export type CheckQuotaType = { + cpu: number; + memory: number; + storage: number; + gpu?: { + type: string; + amount: number; + }; +}; diff --git a/frontend/providers/template/src/types/user.ts b/frontend/providers/template/src/types/user.ts index 6920181fb589..6c51cb9b897e 100644 --- a/frontend/providers/template/src/types/user.ts +++ b/frontend/providers/template/src/types/user.ts @@ -22,3 +22,17 @@ export type Session = { const sessionKey = 'session'; export { sessionKey }; + +export type UserQuotaItemType = { + type: 'cpu' | 'memory' | 'storage' | 'gpu'; + used: number; + limit: number; +}; + +export type userPriceType = { + cpu: number; + memory: number; + storage: number; + nodeports: number; + gpu?: { alias: string; type: string; price: number; inventory: number; vm: number }[]; +}; diff --git a/frontend/providers/template/src/utils/usage.ts b/frontend/providers/template/src/utils/usage.ts new file mode 100644 index 000000000000..3d6a0756571b --- /dev/null +++ b/frontend/providers/template/src/utils/usage.ts @@ -0,0 +1,136 @@ +import JsYaml from 'js-yaml'; + +export interface ResourceUsage { + cpu: { min: number; max: number }; + memory: { min: number; max: number }; + storage: { min: number; max: number }; + nodeport: number; +} + +export function getResourceUsage(yamlList: string[]): ResourceUsage { + let totalCpuMin = 0; + let totalCpuMax = 0; + let totalMemoryMin = 0; + let totalMemoryMax = 0; + let totalStorageMin = 0; + let totalStorageMax = 0; + let totalNodeport = 0; + + for (const yaml of yamlList) { + for (const yamlObj of JsYaml.loadAll(yaml)) { + const resource = parseResourceUsage(yamlObj); + totalCpuMin += resource.cpu.min; + totalCpuMax += resource.cpu.max; + totalMemoryMin += resource.memory.min; + totalMemoryMax += resource.memory.max; + totalStorageMin += resource.storage.min; + totalStorageMax += resource.storage.max; + totalNodeport += resource.nodeport; + } + } + + return { + cpu: { min: totalCpuMin, max: totalCpuMax }, + memory: { min: totalMemoryMin, max: totalMemoryMax }, + storage: { min: totalStorageMin, max: totalStorageMax }, + nodeport: totalNodeport + }; +} + +function parseResourceUsage(yamlObj: any): ResourceUsage { + const kind = yamlObj?.kind; + + let cpu = 0; + let memory = 0; + let storage = 0; + let nodeport = 0; + + let replicasMin = + parseInt( + yamlObj.metadata?.annotations?.['deploy.cloud.sealos.io/minReplicas'] ?? + yamlObj.spec?.replicas?.toString() ?? + '1' + ) || 1; + if (replicasMin === 0) replicasMin = 1; + let replicasMax = + parseInt( + yamlObj.metadata?.annotations?.['deploy.cloud.sealos.io/maxReplicas'] ?? + yamlObj.spec?.replicas?.toString() ?? + '1' + ) || replicasMin; + if (replicasMin > replicasMax) { + replicasMax = replicasMin; + } + + if (kind === 'Deployment' || kind === 'StatefulSet') { + const containers = yamlObj.spec?.template?.spec?.containers; + if (containers) { + for (const container of containers) { + cpu += convertCpu(container.resources?.limits?.cpu || '0m'); + memory += convertMemory(container.resources?.limits?.memory || '0'); + } + } + + if (yamlObj.spec?.volumeClaimTemplates) { + for (const volumeClaim of yamlObj.spec?.volumeClaimTemplates) { + storage += convertMemory(volumeClaim.spec?.resources?.requests?.storage || '0'); + } + } + } else if (kind === 'Service' && yamlObj.spec?.type === 'NodePort') { + nodeport = yamlObj.spec.ports?.length || 0; + } + + return { + cpu: { min: cpu * replicasMin, max: cpu * replicasMax }, + memory: { min: memory * replicasMin, max: memory * replicasMax }, + storage: { + min: storage * replicasMin, + max: storage * replicasMax + }, + nodeport: nodeport + }; +} + +function convertCpu(cpu: string): number { + cpu = String(cpu); + if (cpu.slice(-1) === 'm') { + return parseFloat(cpu.slice(0, -1)) || 0; + } else { + return parseFloat(cpu) * 100 || 0; + } +} + +// https://kubernetes.io/zh-cn/docs/tasks/configure-pod-container/assign-memory-resource/#memory-units +function convertMemory(memory: string): number { + memory = String(memory); + const memoryValue = parseFloat(memory) || 0; + + switch (memory.replace(/[0-9.]/g, '')) { + case 'E': + return (memoryValue * 1000 * 1000 * 1000 * 1000) / (1024 * 1024); + case 'Ei': + return memoryValue * 1024 * 1024 * 1024 * 1024; + case 'P': + return (memoryValue * 1000 * 1000 * 1000) / (1024 * 1024); + case 'Pi': + return memoryValue * 1024 * 1024 * 1024; + case 'T': + return (memoryValue * 1000 * 1000) / (1024 * 1024); + case 'Ti': + return memoryValue * 1024 * 1024; + case 'G': + return (memoryValue * 1000) / 1024; + case 'Gi': + return memoryValue * 1024; + case 'M': + return (memoryValue * 1000 * 1000) / (1024 * 1024); + case 'Mi': + return memoryValue; + case 'K': + return memoryValue / 1000 / 1024; + case 'Ki': + return memoryValue / 1024; + default: + return memoryValue / (1024 * 1024); // Convert bytes to MiB + } +}