diff --git a/api/src/caching/helpers.ts b/api/src/caching/helpers.ts index d63b65d9a..b60fa1274 100644 --- a/api/src/caching/helpers.ts +++ b/api/src/caching/helpers.ts @@ -54,6 +54,7 @@ export const cacheKeys = { getAuditors: "getAuditors", getProviderList: "getProviderList", getChainStats: "getChainStats", + getProviderRegions: "getProviderRegions", getMainnetNodes: "getMainnetNodes", getTestnetNodes: "getTestnetNodes", getSandboxNodes: "getSandboxNodes", diff --git a/api/src/db/providerDataProvider.ts b/api/src/db/providerDataProvider.ts new file mode 100644 index 000000000..315565719 --- /dev/null +++ b/api/src/db/providerDataProvider.ts @@ -0,0 +1,20 @@ +import { Provider, ProviderAttribute } from "@shared/dbSchemas/akash"; +import { getProviderAttributesSchema } from "@src/providers/githubProvider"; + +export async function getProviderRegions() { + const providerAttributesSchema = await getProviderAttributesSchema(); + const regions = providerAttributesSchema["location-region"].values; + + const providerRegions = await Provider.findAll({ + attributes: ["owner"], + include: [{ model: ProviderAttribute, attributes: [["value", "location_region"]], where: { key: "location-region" } }], + raw: true + }); + + const result = regions.map((region) => { + const providers = providerRegions.filter((x) => x["providerAttributes.location_region"] === region.key).map((x) => x.owner); + return { ...region, providers }; + }); + + return result; +} diff --git a/api/src/providers/githubProvider.ts b/api/src/providers/githubProvider.ts index 25645c669..3488de2f0 100644 --- a/api/src/providers/githubProvider.ts +++ b/api/src/providers/githubProvider.ts @@ -36,4 +36,4 @@ export async function getAuditors() { }); return response; -} +} \ No newline at end of file diff --git a/api/src/routers/apiRouter.ts b/api/src/routers/apiRouter.ts index 302d0490b..c2bac03f2 100644 --- a/api/src/routers/apiRouter.ts +++ b/api/src/routers/apiRouter.ts @@ -23,6 +23,7 @@ import { cacheKeys, cacheResponse } from "@src/caching/helpers"; import axios from "axios"; import { getMarketData } from "@src/providers/marketDataProvider"; import { getAuditors, getProviderAttributesSchema } from "@src/providers/githubProvider"; +import { getProviderRegions } from "@src/db/providerDataProvider"; export const apiRouter = express.Router(); @@ -336,6 +337,14 @@ apiRouter.get( }) ); +apiRouter.get( + "/provider-regions", + asyncHandler(async (req, res) => { + const response = await cacheResponse(60 * 5, cacheKeys.getProviderRegions, getProviderRegions); + res.send(response); + }) +); + apiRouter.post( "/pricing", express.json(), diff --git a/deploy-web/src/components/newDeploymentWizard/BidRow.tsx b/deploy-web/src/components/newDeploymentWizard/BidRow.tsx index e8cebee94..2ed21152a 100644 --- a/deploy-web/src/components/newDeploymentWizard/BidRow.tsx +++ b/deploy-web/src/components/newDeploymentWizard/BidRow.tsx @@ -19,6 +19,7 @@ import { cx } from "@emotion/css"; import { Uptime } from "../providers/Uptime"; import { udenomToDenom } from "@src/utils/mathHelpers"; import { hasSomeParentTheClass } from "@src/utils/domUtils"; +import WarningIcon from "@mui/icons-material/Warning"; const useStyles = makeStyles()(theme => ({ root: { @@ -149,9 +150,9 @@ export const BidRow: React.FunctionComponent = ({ bid, selectedBid, handl {provider.name ? ( e.stopPropagation()}> - {provider.name?.length > 25 ? ( + {provider.name?.length > 20 ? ( - {getSplitText(provider.name, 10, 10)} + {getSplitText(provider.name, 4, 13)} ) : ( provider.name @@ -160,7 +161,7 @@ export const BidRow: React.FunctionComponent = ({ bid, selectedBid, handl ) : (
-
{getSplitText(provider.hostUri, 15, 15)}
+
{getSplitText(provider.hostUri, 4, 13)}
)} @@ -177,7 +178,13 @@ export const BidRow: React.FunctionComponent = ({ bid, selectedBid, handl ) : ( - No + + No + + This provider is not audited, which may result in a lesser quality experience.}> + + + )}
diff --git a/deploy-web/src/components/newDeploymentWizard/CreateLease.tsx b/deploy-web/src/components/newDeploymentWizard/CreateLease.tsx index 364a579ec..93ec98a23 100644 --- a/deploy-web/src/components/newDeploymentWizard/CreateLease.tsx +++ b/deploy-web/src/components/newDeploymentWizard/CreateLease.tsx @@ -42,6 +42,7 @@ import { BidDto } from "@src/types/deployment"; import { BidCountdownTimer } from "./BidCountdownTimer"; import { CustomNextSeo } from "../shared/CustomNextSeo"; import { RouteStepKeys } from "@src/utils/constants"; +import VerifiedUserIcon from "@mui/icons-material/VerifiedUser"; import { useProviderList } from "@src/queries/useProvidersQuery"; const yaml = require("js-yaml"); @@ -134,7 +135,7 @@ export const CreateLease: React.FunctionComponent = ({ dseq }) => { } }); - if (!isAdded && provider.hostUri.includes(search)) { + if (!isAdded && provider?.hostUri.includes(search)) { fBids.push(bid.id); } } @@ -401,7 +402,12 @@ export const CreateLease: React.FunctionComponent = ({ dseq }) => { setIsFilteringAudited(value)} color="secondary" size="small" />} - label="Audited" + label={ + + Audited + + + } /> {!isLoadingBids && allClosed && ( diff --git a/deploy-web/src/components/newDeploymentWizard/ManifestEdit.tsx b/deploy-web/src/components/newDeploymentWizard/ManifestEdit.tsx index d6d6e13b7..4c4452cc8 100644 --- a/deploy-web/src/components/newDeploymentWizard/ManifestEdit.tsx +++ b/deploy-web/src/components/newDeploymentWizard/ManifestEdit.tsx @@ -16,7 +16,7 @@ import ViewPanel from "../shared/ViewPanel"; import { DeploymentDepositModal } from "../deploymentDetail/DeploymentDepositModal"; import { TransactionMessageData } from "@src/utils/TransactionMessageData"; import { saveDeploymentManifestAndName } from "@src/utils/deploymentLocalDataUtils"; -import { UrlService } from "@src/utils/urlUtils"; +import { UrlService, handleDocClick } from "@src/utils/urlUtils"; import { event } from "nextjs-google-analytics"; import { AnalyticsEvents } from "@src/utils/analytics"; import { PrerequisiteList } from "./PrerequisiteList"; @@ -27,6 +27,7 @@ import { updateWallet } from "@src/utils/walletUtils"; import sdlStore from "@src/store/sdlStore"; import { useAtom } from "jotai"; import { SdlBuilder, SdlBuilderRefType } from "./SdlBuilder"; +import { validateDeploymentData } from "@src/utils/deploymentUtils"; const yaml = require("js-yaml"); @@ -101,7 +102,7 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s const doc = yaml.load(yamlStr); const dd = await deploymentData.NewDeploymentData(settings.apiEndpoint, doc, dseq, address, deposit, depositorAddress); - validateDeploymentData(dd); + validateDeploymentData(dd, selectedTemplate); setSdlDenom(dd.deposit.denom); @@ -120,35 +121,14 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s } } - function handleDocClick(ev, url) { - ev.preventDefault(); - - window.open(url, "_blank"); - } - - /** - * Validate values to change in the template - */ - function validateDeploymentData(deploymentData) { - if (selectedTemplate?.valuesToChange) { - for (const valueToChange of selectedTemplate.valuesToChange) { - if (valueToChange.field === "accept" || valueToChange.field === "env") { - const serviceNames = Object.keys(deploymentData.sdl.services); - for (const serviceName of serviceNames) { - if ( - deploymentData.sdl.services[serviceName].expose?.some(e => e.accept?.includes(valueToChange.initialValue)) || - deploymentData.sdl.services[serviceName].env?.some(e => e?.includes(valueToChange.initialValue)) - ) { - let error = new Error(`Template value of "${valueToChange.initialValue}" needs to be changed`); - error.name = "TemplateValidation"; - - throw error; - } - } - } - } + const handleCreateDeployment = async () => { + if (selectedSdlEditMode === "builder") { + const valid = await sdlBuilderRef.current.validate(); + if (!valid) return; } - } + + setIsCheckingPrerequisites(true); + }; const onPrerequisiteContinue = () => { setIsCheckingPrerequisites(false); @@ -287,7 +267,7 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s variant="contained" color="secondary" disabled={isCreatingDeployment || !!parsingError || !editedManifest} - onClick={() => setIsCheckingPrerequisites(true)} + onClick={() => handleCreateDeployment()} sx={{ whiteSpace: "nowrap", width: { xs: "100%", sm: "auto" } }} > {isCreatingDeployment ? ( diff --git a/deploy-web/src/components/newDeploymentWizard/SdlBuilder.tsx b/deploy-web/src/components/newDeploymentWizard/SdlBuilder.tsx index 0acce7fde..9d39e7349 100644 --- a/deploy-web/src/components/newDeploymentWizard/SdlBuilder.tsx +++ b/deploy-web/src/components/newDeploymentWizard/SdlBuilder.tsx @@ -17,6 +17,7 @@ interface Props { export type SdlBuilderRefType = { getSdl: () => string; + validate: () => Promise; }; export const SdlBuilder = React.forwardRef(({ sdlString, setEditedManifest }, ref) => { @@ -42,18 +43,23 @@ export const SdlBuilder = React.forwardRef(({ sdlStrin const [serviceCollapsed, setServiceCollapsed] = useState([]); React.useImperativeHandle(ref, () => ({ - getSdl: getSdl + getSdl: getSdl, + validate: async () => { + return await trigger(); + } })); useEffect(() => { const { unsubscribe } = watch(data => { - const sdl = generateSdl({ services: data.services as Service[] }); + const sdl = generateSdl(data.services as Service[]); setEditedManifest(sdl); }); try { - const services = createAndValidateSdl(sdlString); - setValue("services", services as Service[]); + if (!!sdlString) { + const services = createAndValidateSdl(sdlString); + setValue("services", services as Service[]); + } } catch (error) { setError("Error importing SDL"); } @@ -66,7 +72,7 @@ export const SdlBuilder = React.forwardRef(({ sdlStrin }, [watch]); const getSdl = () => { - return generateSdl({ services: _services }); + return generateSdl(_services); }; const createAndValidateSdl = (yamlStr: string) => { @@ -110,7 +116,6 @@ export const SdlBuilder = React.forwardRef(({ sdlStrin {services.map((service, serviceIndex) => ( ({})); @@ -43,6 +46,7 @@ export const TemplateList: React.FunctionComponent = ({ setSelectedTempla const router = useRouter(); const fileUploadRef = useRef(null); const [previewTemplates, setPreviewTemplates] = useState([]); + const [, setSdlEditMode] = useAtom(sdlStore.selectedSdlEditMode); useEffect(() => { if (templates) { @@ -81,7 +85,8 @@ export const TemplateList: React.FunctionComponent = ({ setSelectedTempla }; function onSDLBuilderClick() { - router.push(UrlService.sdlBuilder()); + setSdlEditMode("builder"); + router.push(UrlService.newDeployment({ step: RouteStepKeys.editDeployment })); } return ( @@ -110,6 +115,13 @@ export const TemplateList: React.FunctionComponent = ({ setSelectedTempla onClick={() => router.push(UrlService.newDeployment({ step: RouteStepKeys.editDeployment, templateId: helloWorldTemplate.code }))} /> + } + onClick={() => router.push(UrlService.rentGpus())} + /> + = ({ setSelectedTempla icon={} onClick={() => fromFile()} /> - - } - onClick={() => selectTemplate(emptyTemplate)} - /> diff --git a/deploy-web/src/components/providers/AuditorButton.tsx b/deploy-web/src/components/providers/AuditorButton.tsx index e082f3a59..fa012d9d7 100644 --- a/deploy-web/src/components/providers/AuditorButton.tsx +++ b/deploy-web/src/components/providers/AuditorButton.tsx @@ -1,5 +1,5 @@ import { useState, MouseEvent } from "react"; -import SecurityIcon from "@mui/icons-material/Security"; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import { IconButton } from "@mui/material"; import { AuditorsModal } from "./AuditorsModal"; import { ClientProviderDetailWithStatus, ClientProviderList } from "@src/types/provider"; @@ -28,7 +28,7 @@ export const AuditorButton: React.FunctionComponent = ({ provider }) => { return ( <> - + {isViewingAuditors && } diff --git a/deploy-web/src/components/providers/AuditorsModal.tsx b/deploy-web/src/components/providers/AuditorsModal.tsx index fb75f650d..8d5a4685f 100644 --- a/deploy-web/src/components/providers/AuditorsModal.tsx +++ b/deploy-web/src/components/providers/AuditorsModal.tsx @@ -78,9 +78,9 @@ export const AuditorsModal: React.FunctionComponent = ({ attributes, onCl {a.value} {a.auditedBy - .filter(x => auditors.some(y => y.address === x)) + .filter(x => auditors?.some(y => y.address === x)) .map(x => { - const auditor = auditors.find(y => y.address === x); + const auditor = auditors?.find(y => y.address === x); return (
({ root: { @@ -238,15 +238,15 @@ export const ProviderListRow: React.FunctionComponent = ({ provider }) => {provider.isAudited ? ( <> - + Yes ) : ( <> - - + No + )} diff --git a/deploy-web/src/components/sdl/AcceptFormControl.tsx b/deploy-web/src/components/sdl/AcceptFormControl.tsx index b07758de0..5d8f56fc7 100644 --- a/deploy-web/src/components/sdl/AcceptFormControl.tsx +++ b/deploy-web/src/components/sdl/AcceptFormControl.tsx @@ -69,7 +69,7 @@ export const AcceptFormControl = forwardRef(({ control, se Accept - List of hosts to accept connections for.}> + List of hosts/domains to accept connections for.}> diff --git a/deploy-web/src/components/sdl/AdvancedConfig.tsx b/deploy-web/src/components/sdl/AdvancedConfig.tsx new file mode 100644 index 000000000..94972c3b4 --- /dev/null +++ b/deploy-web/src/components/sdl/AdvancedConfig.tsx @@ -0,0 +1,86 @@ +import { ReactNode, useState } from "react"; +import { Control } from "react-hook-form"; +import { Box, Button, Collapse, Paper, Typography, useTheme } from "@mui/material"; +import { RentGpusFormValues, Service } from "@src/types"; +import { ExpandMore } from "../shared/ExpandMore"; +import { EnvFormModal } from "./EnvFormModal"; +import { CommandFormModal } from "./CommandFormModal"; +import { ExposeFormModal } from "./ExposeFormModal"; +import { EnvVarList } from "./EnvVarList"; +import { CommandList } from "./CommandList"; +import { ExposeList } from "./ExposeList"; +import { ProviderAttributesSchema } from "@src/types/providerAttributes"; +import { PersistentStorage } from "./PersistentStorage"; + +type Props = { + providerAttributesSchema: ProviderAttributesSchema; + currentService: Service; + control: Control; + children?: ReactNode; +}; + +export const AdvancedConfig: React.FunctionComponent = ({ control, currentService, providerAttributesSchema }) => { + const theme = useTheme(); + const [expanded, setIsAdvancedOpen] = useState(false); + const [isEditingCommands, setIsEditingCommands] = useState(false); + const [isEditingEnv, setIsEditingEnv] = useState(false); + const [isEditingExpose, setIsEditingExpose] = useState(false); + + return ( + + {/** Edit Environment Variables */} + {isEditingEnv && ( + setIsEditingEnv(null)} serviceIndex={0} envs={currentService.env} hasSecretOption={false} /> + )} + {/** Edit Commands */} + {isEditingCommands && setIsEditingCommands(null)} serviceIndex={0} />} + {/** Edit Expose */} + {isEditingExpose && ( + setIsEditingExpose(null)} + serviceIndex={0} + expose={currentService.expose} + services={[currentService]} + providerAttributesSchema={providerAttributesSchema} + /> + )} + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/deploy-web/src/components/sdl/CommandFormModal.tsx b/deploy-web/src/components/sdl/CommandFormModal.tsx index 9d7465a5d..995d5b5a9 100644 --- a/deploy-web/src/components/sdl/CommandFormModal.tsx +++ b/deploy-web/src/components/sdl/CommandFormModal.tsx @@ -6,7 +6,6 @@ import { Box, InputLabel, Paper, TextareaAutosize, TextField, useTheme } from "@ import { SdlBuilderFormValues } from "@src/types"; type Props = { - open: boolean; serviceIndex: number; onClose: () => void; control: Control; @@ -22,14 +21,14 @@ const useStyles = makeStyles()(theme => ({ } })); -export const CommandFormModal: React.FunctionComponent = ({ open, control, serviceIndex, onClose }) => { +export const CommandFormModal: React.FunctionComponent = ({ control, serviceIndex, onClose }) => { const { classes } = useStyles(); const theme = useTheme(); return ( >; +}; + +const useStyles = makeStyles()(theme => ({ + editLink: { + color: theme.palette.secondary.light, + textDecoration: "underline", + cursor: "pointer", + fontWeight: "normal", + fontSize: ".8rem" + }, + formValue: { + color: theme.palette.grey[500] + } +})); + +export const CommandList: React.FunctionComponent = ({ currentService, setIsEditingCommands, serviceIndex }) => { + const { classes } = useStyles(); + + return ( + + + + Commands + + + + Custom command use when executing container. +
+
+ An example and popular use case is to run a bash script to install packages or run specific commands. + + } + > + +
+ + setIsEditingCommands(serviceIndex !== undefined ? serviceIndex : true)}> + Edit + +
+ + {currentService.command.command.length > 0 ? ( + +
{currentService.command.command}
+ {currentService.command.arg} +
+ ) : ( + + None + + )} +
+ ); +}; diff --git a/deploy-web/src/components/sdl/CpuFormControl.tsx b/deploy-web/src/components/sdl/CpuFormControl.tsx new file mode 100644 index 000000000..475711b22 --- /dev/null +++ b/deploy-web/src/components/sdl/CpuFormControl.tsx @@ -0,0 +1,120 @@ +import { ReactNode } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, FormControl, FormHelperText, Slider, TextField, Typography, useTheme } from "@mui/material"; +import { RentGpusFormValues, SdlBuilderFormValues, Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; +import { Control, Controller } from "react-hook-form"; +import SpeedIcon from "@mui/icons-material/Speed"; +import { cx } from "@emotion/css"; +import { validationConfig } from "../shared/akash/units"; + +type Props = { + serviceIndex: number; + children?: ReactNode; + control: Control; + currentService: Service; +}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + }, + textField: { + width: "100%" + } +})); + +export const CpuFormControl: React.FunctionComponent = ({ control, serviceIndex, currentService }) => { + const { classes } = useStyles(); + const theme = useTheme(); + + return ( + { + if (!v) return "CPU amount is required."; + + const _value = v || 0; + + if (currentService.count === 1 && _value < 0.1) { + return "Minimum amount of CPU for a single service instance is 0.1."; + } else if (currentService.count === 1 && _value > validationConfig.maxCpuAmount) { + return `Maximum amount of CPU for a single service instance is ${validationConfig.maxCpuAmount}.`; + } else if (currentService.count > 1 && currentService.count * _value > validationConfig.maxGroupCpuCount) { + return `Maximum total amount of CPU for a single service instance group is ${validationConfig.maxGroupCpuCount}.`; + } + + return true; + } + }} + render={({ field, fieldState }) => ( + + + + + + CPU + + + The amount of vCPU's required for this workload. +
+
+ The maximum for a single instance is {validationConfig.maxCpuAmount} vCPU's. +
+
+ The maximum total multiplied by the count of instances is 512 vCPU's. + + } + > + +
+
+ + field.onChange(parseFloat(event.target.value))} + inputProps={{ min: 0.1, max: validationConfig.maxCpuAmount, step: 0.1 }} + size="small" + sx={{ width: "100px", marginLeft: "1rem" }} + /> +
+ + field.onChange(newValue)} + /> + + {!!fieldState.error && {fieldState.error.message}} +
+
+ )} + /> + ); +}; diff --git a/deploy-web/src/components/sdl/EnvFormModal.tsx b/deploy-web/src/components/sdl/EnvFormModal.tsx index 8906d9976..be5877314 100644 --- a/deploy-web/src/components/sdl/EnvFormModal.tsx +++ b/deploy-web/src/components/sdl/EnvFormModal.tsx @@ -2,18 +2,18 @@ import { ReactNode, useEffect } from "react"; import { makeStyles } from "tss-react/mui"; import { Popup } from "../shared/Popup"; import { Control, Controller, useFieldArray } from "react-hook-form"; -import { Box, IconButton, Paper, Switch, TextField, useTheme } from "@mui/material"; -import { EnvironmentVariable, SdlBuilderFormValues } from "@src/types"; +import { Box, IconButton, Paper, Switch, TextField, Typography, useTheme } from "@mui/material"; +import { EnvironmentVariable, RentGpusFormValues, SdlBuilderFormValues } from "@src/types"; import DeleteIcon from "@mui/icons-material/Delete"; import { nanoid } from "nanoid"; import { CustomTooltip } from "../shared/CustomTooltip"; type Props = { - open: boolean; serviceIndex: number; onClose: () => void; envs: EnvironmentVariable[]; - control: Control; + control: Control; + hasSecretOption?: boolean; children?: ReactNode; }; @@ -26,7 +26,7 @@ const useStyles = makeStyles()(theme => ({ } })); -export const EnvFormModal: React.FunctionComponent = ({ open, control, serviceIndex, envs: _envs, onClose }) => { +export const EnvFormModal: React.FunctionComponent = ({ control, serviceIndex, envs: _envs, onClose, hasSecretOption = true }) => { const { classes } = useStyles(); const theme = useTheme(); const { @@ -66,7 +66,7 @@ export const EnvFormModal: React.FunctionComponent = ({ open, control, se return ( = ({ open, control, se maxWidth="sm" enableCloseOnBackdropClick > - {envs.map((env, envIndex) => { - return ( - - - ( - field.onChange(event.target.value)} - /> - )} - /> + + {envs.map((env, envIndex) => { + return ( + + + ( + field.onChange(event.target.value)} + /> + )} + /> - ( - field.onChange(event.target.value)} - /> - )} - /> - + ( + field.onChange(event.target.value)} + /> + )} + /> + - 0 ? "space-around" : "flex-end" - }} - > - {envIndex > 0 && ( - removeEnv(envIndex)} size="small"> - - - )} + 0 ? "space-around" : "flex-end", + width: "45px" + }} + > + {envIndex > 0 && ( + removeEnv(envIndex)} size="small"> + + + )} - ( - - - + {hasSecretOption && ( + ( + + + Secret + + + This is for secret variables containing sensitive information you don't want to be saved in your template. + + + } + > + + + )} + /> )} - /> - - - ); - })} + + + ); + })} + ); }; diff --git a/deploy-web/src/components/sdl/EnvVarList.tsx b/deploy-web/src/components/sdl/EnvVarList.tsx new file mode 100644 index 000000000..4033866e0 --- /dev/null +++ b/deploy-web/src/components/sdl/EnvVarList.tsx @@ -0,0 +1,83 @@ +import { Dispatch, ReactNode, SetStateAction } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, Typography } from "@mui/material"; +import { Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; + +type Props = { + currentService: Service; + serviceIndex?: number; + children?: ReactNode; + setIsEditingEnv: Dispatch>; +}; + +const useStyles = makeStyles()(theme => ({ + editLink: { + color: theme.palette.secondary.light, + textDecoration: "underline", + cursor: "pointer", + fontWeight: "normal", + fontSize: ".8rem" + }, + formValue: { + color: theme.palette.grey[500] + } +})); + +export const EnvVarList: React.FunctionComponent = ({ currentService, setIsEditingEnv, serviceIndex }) => { + const { classes } = useStyles(); + + return ( + + + + Environment Variables + + + + A list of environment variables to expose to the running container. +
+
+ + View official documentation. + + + } + > + +
+ + setIsEditingEnv(serviceIndex !== undefined ? serviceIndex : true)} + > + Edit + +
+ + {currentService.env.length > 0 ? ( + currentService.env.map((e, i) => ( + +
+ {e.key}= + + {e.value} + +
+
+ )) + ) : ( + + None + + )} +
+ ); +}; diff --git a/deploy-web/src/components/sdl/ExposeFormModal.tsx b/deploy-web/src/components/sdl/ExposeFormModal.tsx index a6256cc69..e79651ce7 100644 --- a/deploy-web/src/components/sdl/ExposeFormModal.tsx +++ b/deploy-web/src/components/sdl/ExposeFormModal.tsx @@ -1,7 +1,7 @@ import { ReactNode, useRef } from "react"; import { Popup } from "../shared/Popup"; import { Control, Controller, useFieldArray } from "react-hook-form"; -import { Box, Grid, IconButton, InputAdornment, MenuItem, Select, TextField } from "@mui/material"; +import { Box, Checkbox, FormControlLabel, Grid, IconButton, InputAdornment, MenuItem, Select, TextField } from "@mui/material"; import { Expose, SdlBuilderFormValues, Service } from "@src/types"; import DeleteIcon from "@mui/icons-material/Delete"; import { AcceptFormControl, AcceptRefType } from "./AcceptFormControl"; @@ -16,7 +16,6 @@ import { HttpOptionsFormControl } from "./HttpOptionsFormControl"; import { ProviderAttributesSchema } from "@src/types/providerAttributes"; type Props = { - open: boolean; serviceIndex: number; onClose: () => void; control: Control; @@ -26,15 +25,7 @@ type Props = { providerAttributesSchema: ProviderAttributesSchema; }; -export const ExposeFormModal: React.FunctionComponent = ({ - open, - control, - serviceIndex, - onClose, - expose: _expose, - services, - providerAttributesSchema -}) => { +export const ExposeFormModal: React.FunctionComponent = ({ control, serviceIndex, onClose, expose: _expose, services, providerAttributesSchema }) => { const acceptRef = useRef(); const toRef = useRef(); const { @@ -78,7 +69,7 @@ export const ExposeFormModal: React.FunctionComponent = ({ return ( @@ -139,7 +130,7 @@ export const ExposeFormModal: React.FunctionComponent = ({ > - + = ({ )} /> - + = ({ )} /> - + = ({ )} /> + + + + ( + + } + label="Global" + /> + )} + /> + + Check if you want this service to be accessible from outside the datacenter.}> + + + + diff --git a/deploy-web/src/components/sdl/ExposeList.tsx b/deploy-web/src/components/sdl/ExposeList.tsx new file mode 100644 index 000000000..8d3765a09 --- /dev/null +++ b/deploy-web/src/components/sdl/ExposeList.tsx @@ -0,0 +1,99 @@ +import { Dispatch, ReactNode, SetStateAction } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, Typography } from "@mui/material"; +import { Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; + +type Props = { + currentService: Service; + serviceIndex?: number; + children?: ReactNode; + setIsEditingExpose: Dispatch>; +}; + +const useStyles = makeStyles()(theme => ({ + editLink: { + color: theme.palette.secondary.light, + textDecoration: "underline", + cursor: "pointer", + fontWeight: "normal", + fontSize: ".8rem" + }, + formValue: { + color: theme.palette.grey[500] + } +})); + +export const ExposeList: React.FunctionComponent = ({ currentService, setIsEditingExpose, serviceIndex }) => { + const { classes } = useStyles(); + + return ( + + + + Expose + + + + Expose is a list of port settings describing what can connect to the service. +
+
+ + View official documentation. + + + } + > + +
+ + setIsEditingExpose(serviceIndex !== undefined ? serviceIndex : true)} + > + Edit + +
+ + {currentService.expose?.map((exp, i) => ( + +
+ Port   + + {exp.port} : {exp.as} ({exp.proto}) + +
+
+ Global   + {exp.global ? "True" : "False"} +
+ {exp.ipName && ( +
+ IP Name   + {exp.ipName} +
+ )} +
+ Accept   + + {exp.accept?.length > 0 + ? exp.accept?.map((a, i) => ( + + {a.value} + + )) + : "None"} + +
+
+ ))} +
+ ); +}; diff --git a/deploy-web/src/components/sdl/FormSelect.tsx b/deploy-web/src/components/sdl/FormSelect.tsx index 629f8dfb6..aecc65277 100644 --- a/deploy-web/src/components/sdl/FormSelect.tsx +++ b/deploy-web/src/components/sdl/FormSelect.tsx @@ -1,23 +1,24 @@ import { Autocomplete, Box, ClickAwayListener, TextField } from "@mui/material"; -import { SdlBuilderFormValues } from "@src/types"; +import { RentGpusFormValues, SdlBuilderFormValues } from "@src/types"; import { ProviderAttributeSchemaDetailValue, ProviderAttributesSchema } from "@src/types/providerAttributes"; import { useState } from "react"; import { Control, Controller, FieldPath } from "react-hook-form"; -type ProviderSelectProps = { - control: Control; +type FormSelectProps = { + control: Control; providerAttributesSchema: ProviderAttributesSchema; optionName?: keyof ProviderAttributesSchema; - name: FieldPath; + name: FieldPath; className?: string; requiredMessage?: string; label: string; multiple?: boolean; required?: boolean; disabled?: boolean; + valueType?: "key" | "description "; }; -export const FormSelect: React.FunctionComponent = ({ +export const FormSelect: React.FunctionComponent = ({ control, providerAttributesSchema, optionName, @@ -27,7 +28,8 @@ export const FormSelect: React.FunctionComponent = ({ label, required = providerAttributesSchema[optionName]?.required || false, multiple, - disabled + disabled, + valueType = "description" }) => { const [isOpen, setIsOpen] = useState(false); const options = providerAttributesSchema[optionName]?.values || []; @@ -47,7 +49,7 @@ export const FormSelect: React.FunctionComponent = ({ disabled={disabled} options={options} value={field.value || (multiple ? ([] as any) : null)} - getOptionLabel={option => option?.description} + getOptionLabel={option => (valueType === "key" ? option?.key : option?.description) || ""} defaultValue={multiple ? [] : null} isOptionEqualToValue={(option, value) => option.key === value.key} filterSelectedOptions @@ -80,7 +82,7 @@ export const FormSelect: React.FunctionComponent = ({ {...props} key={option.key} > -
{option.description}
+
{valueType === "key" ? option.key : option.description}
); }} diff --git a/deploy-web/src/components/sdl/GpuFormControl.tsx b/deploy-web/src/components/sdl/GpuFormControl.tsx new file mode 100644 index 000000000..edd07b749 --- /dev/null +++ b/deploy-web/src/components/sdl/GpuFormControl.tsx @@ -0,0 +1,185 @@ +import { ReactNode } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, Checkbox, CircularProgress, FormControl, FormHelperText, MenuItem, Select, Slider, TextField, Typography, useTheme } from "@mui/material"; +import { RentGpusFormValues, SdlBuilderFormValues, Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; +import { Control, Controller } from "react-hook-form"; +import SpeedIcon from "@mui/icons-material/Speed"; +import { cx } from "@emotion/css"; +import { ProviderAttributesSchema } from "@src/types/providerAttributes"; +import { gpuVendors } from "../shared/akash/gpu"; +import { FormSelect } from "./FormSelect"; +import { validationConfig } from "../shared/akash/units"; + +type Props = { + serviceIndex: number; + hasGpu: boolean; + children?: ReactNode; + control: Control; + providerAttributesSchema: ProviderAttributesSchema; + currentService: Service; +}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + }, + textField: { + width: "100%" + } +})); + +export const GpuFormControl: React.FunctionComponent = ({ providerAttributesSchema, control, serviceIndex, hasGpu, currentService }) => { + const { classes } = useStyles(); + const theme = useTheme(); + + return ( + + { + if (!v) return "GPU amount is required."; + + const _value = v || 0; + + if (_value < 1) return "GPU amount must be greater than 0."; + else if (currentService.count === 1 && _value > validationConfig.maxGpuAmount) { + return `Maximum amount of GPU for a single service instance is ${validationConfig.maxGpuAmount}.`; + } else if (currentService.count > 1 && currentService.count * _value > validationConfig.maxGroupGpuCount) { + return `Maximum total amount of GPU for a single service instance group is ${validationConfig.maxGroupGpuCount}.`; + } + return true; + } + }} + render={({ field, fieldState }) => ( + + + + + + GPU + + + The amount of GPUs required for this workload. +
+
+ You can also specify the GPU vendor and model you want specifically. If you don't specify any model, providers with any GPU model will + bid on your workload. +
+
+ + View official documentation. + + + } + > + +
+
+ + ( + + )} + /> +
+ + {hasGpu && ( + + field.onChange(parseFloat(event.target.value))} + inputProps={{ min: 1, step: 1, max: validationConfig.maxGpuAmount }} + size="small" + sx={{ width: "100px" }} + /> + + )} +
+ + {hasGpu && ( + field.onChange(newValue)} + /> + )} + + {!!fieldState.error && {fieldState.error.message}} +
+ )} + /> + + {hasGpu && ( +
+ + ( + + )} + /> + + + + {providerAttributesSchema ? ( + + ) : ( + + + + Loading GPU models... + + + )} + +
+ )} +
+ ); +}; diff --git a/deploy-web/src/components/sdl/ImageSelect.tsx b/deploy-web/src/components/sdl/ImageSelect.tsx new file mode 100644 index 000000000..ab4342976 --- /dev/null +++ b/deploy-web/src/components/sdl/ImageSelect.tsx @@ -0,0 +1,256 @@ +import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, ClickAwayListener, IconButton, InputAdornment, Paper, Popper, TextField, useTheme } from "@mui/material"; +import { ApiTemplate, RentGpusFormValues, SdlBuilderFormValues, Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { Control, Controller } from "react-hook-form"; +import Image from "next/image"; +import Link from "next/link"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { useGpuTemplates } from "@src/hooks/useGpuTemplates"; + +type Props = { + children?: ReactNode; + control: Control; + currentService: Service; + onSelectTemplate: (template: ApiTemplate) => void; +}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + }, + textField: { + width: "100%" + } +})); + +export const ImageSelect: React.FunctionComponent = ({ control, currentService, onSelectTemplate }) => { + const theme = useTheme(); + const { gpuTemplates } = useGpuTemplates(); + const [hoveredTemplate, setHoveredTemplate] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [popperWidth, setPopperWidth] = useState(null); + const eleRefs = useRef(null); + const textFieldRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const filteredGpuTemplates = gpuTemplates.filter(x => x.name.toLowerCase().includes(currentService.image)); + const open = Boolean(anchorEl) && filteredGpuTemplates.length > 0; + + useEffect(() => { + // Populate ref list + gpuTemplates.forEach(template => (eleRefs[template.id] = { current: null })); + }, [gpuTemplates]); + + // Effect that scrolls active element when it changes + useLayoutEffect(() => { + if (selectedTemplate) { + eleRefs[selectedTemplate.id].current?.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" }); + } + }, [gpuTemplates, selectedTemplate]); + + useLayoutEffect(() => { + if (!popperWidth && textFieldRef.current) { + setPopperWidth(textFieldRef.current?.offsetWidth); + } + }, [textFieldRef.current, popperWidth]); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + setAnchorEl(event.currentTarget); + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + + if (selectedTemplate) { + onSelectTemplate(selectedTemplate); + } + + onClose(); + } + + if (event.key === "ArrowUp") { + if (hoveredTemplate || selectedTemplate) { + const index = filteredGpuTemplates.findIndex(x => x.id === hoveredTemplate?.id || x.id === selectedTemplate?.id); + const newIndex = (index - 1 + filteredGpuTemplates.length) % filteredGpuTemplates.length; + + setSelectedTemplate(filteredGpuTemplates[newIndex]); + } else { + setSelectedTemplate(filteredGpuTemplates[filteredGpuTemplates.length - 1]); + } + + setHoveredTemplate(null); + } + + if (event.key === "ArrowDown") { + if (hoveredTemplate || selectedTemplate) { + const index = filteredGpuTemplates.findIndex(x => x.id === hoveredTemplate?.id || x.id === selectedTemplate?.id); + const newIndex = (index + 1) % filteredGpuTemplates.length; + + setSelectedTemplate(filteredGpuTemplates[newIndex]); + } else { + setSelectedTemplate(filteredGpuTemplates[0]); + } + + setHoveredTemplate(null); + } + }; + + const _onSelectTemplate = (template: ApiTemplate) => { + setAnchorEl(null); + + onSelectTemplate(template); + }; + + const onClose = () => { + setAnchorEl(null); + setSelectedTemplate(null); + setHoveredTemplate(null); + }; + + return ( + + + + { + const hasValidChars = /^[a-z0-9\-_/:.]+$/.test(value); + + if (!hasValidChars) { + return "Invalid docker image name."; + } + + return true; + } + }} + render={({ field, fieldState }) => ( + field.onChange(event.target.value || "")} + InputProps={{ + startAdornment: ( + + Docker Logo + + ), + endAdornment: ( + + + + + + ) + }} + /> + )} + /> + + + + + {filteredGpuTemplates.map(template => ( + _onSelectTemplate(template)} + onMouseOver={() => { + setHoveredTemplate(template); + setSelectedTemplate(null); + }} + > +
{template.name}
+
+ ))} +
+
+
+
+
+ + + Docker image of the container. +
+
+ Best practices: avoid using :latest image tags as Akash Providers heavily cache images. + + } + > + +
+
+ ); +}; diff --git a/deploy-web/src/components/sdl/ImportSdlModal.tsx b/deploy-web/src/components/sdl/ImportSdlModal.tsx index cd217f248..26558c503 100644 --- a/deploy-web/src/components/sdl/ImportSdlModal.tsx +++ b/deploy-web/src/components/sdl/ImportSdlModal.tsx @@ -43,7 +43,7 @@ export const ImportSdlModal: React.FunctionComponent = ({ onClose, setVal const createAndValidateSdl = (yamlStr: string) => { try { - if (!sdl) return null; + if (!yamlStr) return null; const services = importSimpleSdl(yamlStr, providerAttributesSchema); diff --git a/deploy-web/src/components/sdl/MemoryFormControl.tsx b/deploy-web/src/components/sdl/MemoryFormControl.tsx new file mode 100644 index 000000000..26b031d46 --- /dev/null +++ b/deploy-web/src/components/sdl/MemoryFormControl.tsx @@ -0,0 +1,147 @@ +import { ReactNode } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, FormControl, FormHelperText, MenuItem, Select, Slider, TextField, Typography, useTheme } from "@mui/material"; +import { RentGpusFormValues, SdlBuilderFormValues, Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; +import { Control, Controller } from "react-hook-form"; +import { cx } from "@emotion/css"; +import MemoryIcon from "@mui/icons-material/Memory"; +import { validationConfig, memoryUnits } from "../shared/akash/units"; + +type Props = { + serviceIndex: number; + children?: ReactNode; + control: Control; + currentService: Service; +}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + }, + textField: { + width: "100%" + } +})); + +export const MemoryFormControl: React.FunctionComponent = ({ control, serviceIndex, currentService }) => { + const { classes } = useStyles(); + const theme = useTheme(); + + return ( + { + if (!v) return "Memory amount is required."; + + const currentUnit = memoryUnits.find(u => currentService.profile.ramUnit === u.suffix); + const _value = (v || 0) * currentUnit.value; + + if (currentService.count === 1 && _value < validationConfig.minMemory) { + return "Minimum amount of memory for a single service instance is 1 Mi."; + } else if (currentService.count === 1 && currentService.count * _value > validationConfig.maxMemory) { + return "Maximum amount of memory for a single service instance is 512 Gi."; + } else if (currentService.count > 1 && currentService.count * _value > validationConfig.maxGroupMemory) { + return "Maximum total amount of memory for a single service instance group is 1024 Gi."; + } + + return true; + } + }} + render={({ field, fieldState }) => ( + + + + + + Memory + + + The amount of memory required for this workload. +
+
+ The maximum for a single instance is 512 Gi. +
+
+ The maximum total multiplied by the count of instances is 1024 Gi. + + } + > + +
+
+ + + field.onChange(parseFloat(event.target.value))} + inputProps={{ min: 1, step: 1 }} + size="small" + sx={{ width: "100px" }} + /> + + ( + + )} + /> + +
+ + field.onChange(newValue)} + /> + + {!!fieldState.error && {fieldState.error.message}} +
+
+ )} + /> + ); +}; diff --git a/deploy-web/src/components/sdl/PersistentStorage.tsx b/deploy-web/src/components/sdl/PersistentStorage.tsx new file mode 100644 index 000000000..010efeae5 --- /dev/null +++ b/deploy-web/src/components/sdl/PersistentStorage.tsx @@ -0,0 +1,294 @@ +import { ReactNode } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, Checkbox, FormControl, FormHelperText, InputAdornment, InputLabel, MenuItem, Select, Slider, TextField, Typography, useTheme } from "@mui/material"; +import { RentGpusFormValues, SdlBuilderFormValues, Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; +import { Control, Controller } from "react-hook-form"; +import { cx } from "@emotion/css"; +import StorageIcon from "@mui/icons-material/Storage"; +import { persistentStorageTypes, storageUnits } from "../shared/akash/units"; + + +type Props = { + currentService: Service; + serviceIndex: number; + children?: ReactNode; + control: Control; +}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + }, + textField: { + width: "100%" + } +})); + +export const PersistentStorage: React.FunctionComponent = ({ currentService, serviceIndex, control }) => { + const { classes } = useStyles(); + const theme = useTheme(); + + return ( + + { + if (!v) return "Storage amount is required."; + return true; + } + }} + render={({ field, fieldState }) => ( + + + + + + Persistent Storage + + + The amount of persistent storage required for this workload. +
+
+ This storage is mounted on a persistent volume and persistent through the lifetime of the deployment +
+
+ + View official documentation. + + + } + > + +
+
+ + ( + + )} + /> +
+ + {currentService.profile.hasPersistentStorage && ( + + field.onChange(parseFloat(event.target.value))} + inputProps={{ min: 1, step: 1 }} + size="small" + sx={{ width: "100px" }} + /> + + ( + + )} + /> + + )} +
+ + {currentService.profile.hasPersistentStorage && ( + field.onChange(newValue)} + /> + )} + + {!!fieldState.error && {fieldState.error.message}} +
+ )} + /> + + {currentService.profile.hasPersistentStorage && ( +
+ + { + const hasValidChars = /^[a-z0-9\-]+$/.test(value); + const hasValidStartingChar = /^[a-z]/.test(value); + const hasValidEndingChar = !value.endsWith("-"); + + if (!hasValidChars) { + return "Invalid storage name. It must only be lower case letters, numbers and dashes."; + } else if (!hasValidStartingChar) { + return "Invalid starting character. It can only start with a lowercase letter."; + } else if (!hasValidEndingChar) { + return "Invalid ending character. It can only end with a lowercase letter or number"; + } + + return true; + } + }} + render={({ field, fieldState }) => ( + field.onChange(event.target.value)} + size="small" + sx={{ width: "100%" }} + helperText={!!fieldState.error && fieldState.error.message} + InputProps={{ + endAdornment: ( + + + The name of the persistent volume. +
+
+ Multiple services can gain access to the same volume by name. + + } + > + +
+
+ ) + }} + /> + )} + /> + + + Read only + + + } + /> + +
+ + ( + + Type + + + )} + /> + + ( + field.onChange(event.target.value)} + size="small" + sx={{ width: "100%", marginLeft: ".5rem" }} + helperText={!!fieldState.error && fieldState.error.message} + InputProps={{ + endAdornment: ( + + + The path to mount the persistent volume to. +
+
+ Example: /mnt/data + + } + > + +
+
+ ) + }} + /> + )} + /> +
+
+ )} +
+ ); +}; diff --git a/deploy-web/src/components/sdl/PlacementFormModal.tsx b/deploy-web/src/components/sdl/PlacementFormModal.tsx index 9269c5ab9..6dd1f6d87 100644 --- a/deploy-web/src/components/sdl/PlacementFormModal.tsx +++ b/deploy-web/src/components/sdl/PlacementFormModal.tsx @@ -2,7 +2,7 @@ import { ReactNode, useRef } from "react"; import { makeStyles } from "tss-react/mui"; import { Popup } from "../shared/Popup"; import { Control, Controller } from "react-hook-form"; -import { Box, FormControl, Grid, InputAdornment, InputLabel, MenuItem, Select, TextField, useTheme } from "@mui/material"; +import { Box, FormControl, Grid, InputAdornment, InputLabel, MenuItem, Select, TextField } from "@mui/material"; import { Placement, SdlBuilderFormValues, Service } from "@src/types"; import { FormPaper } from "./FormPaper"; import { SignedByFormControl, SignedByRefType } from "./SignedByFormControl"; @@ -18,7 +18,6 @@ import { USDLabel } from "../shared/UsdLabel"; import { udenomToDenom } from "@src/utils/mathHelpers"; type Props = { - open: boolean; serviceIndex: number; services: Service[]; onClose: () => void; @@ -36,9 +35,8 @@ const useStyles = makeStyles()(theme => ({ } })); -export const PlacementFormModal: React.FunctionComponent = ({ open, control, services, serviceIndex, onClose, placement: _placement }) => { +export const PlacementFormModal: React.FunctionComponent = ({ control, services, serviceIndex, onClose, placement: _placement }) => { const { classes } = useStyles(); - const theme = useTheme(); const signedByRef = useRef(); const attritubesRef = useRef(); const supportedSdlDenoms = useSdlDenoms(); @@ -78,7 +76,7 @@ export const PlacementFormModal: React.FunctionComponent = ({ open, contr return ( ; + className?: string; +}; + +const useStyles = makeStyles()(theme => ({ + disabled: { + color: theme.palette.mode === "dark" ? theme.palette.grey[600] : theme.palette.grey[400], + pointerEvents: "none", + cursor: "default" + } +})); + +export const RegionSelect: React.FunctionComponent = ({ control, className }) => { + const { classes } = useStyles(); + const theme = useTheme(); + const [isOpen, setIsOpen] = useState(false); + const { data: regions, isLoading: isLoadingRegions } = useProviderRegions(); + const options = [ + { + key: "any", + description: "Any region", + providers: [] + }, + ...(regions || []) + ]; + + return ( + ( + + option?.key} + defaultValue={null} + isOptionEqualToValue={(option, value) => option.key === value.key} + filterSelectedOptions + fullWidth + loading={isLoadingRegions} + ChipProps={{ size: "small" }} + onChange={(event, newValue: ProviderAttributeSchemaDetailValue) => { + field.onChange(newValue); + }} + renderInput={params => ( + setIsOpen(false)}> + setIsOpen(prev => !prev)} + sx={{ minHeight: "42px" }} + /> + + )} + renderOption={(props, option) => { + return ( + + {option.key} + {option.key !== "any" && ( + 0 ? "bold" : "normal" + }} + > + ({option.providers.length}) + + )} + + + + + ); + }} + /> + + )} + /> + ); +}; diff --git a/deploy-web/src/components/sdl/RentGpusForm.tsx b/deploy-web/src/components/sdl/RentGpusForm.tsx new file mode 100644 index 000000000..969bb8e5c --- /dev/null +++ b/deploy-web/src/components/sdl/RentGpusForm.tsx @@ -0,0 +1,341 @@ +import { Alert, Box, Button, CircularProgress, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Typography, useTheme } from "@mui/material"; +import { useForm, Controller } from "react-hook-form"; +import { useEffect, useRef, useState } from "react"; +import { ApiTemplate, RentGpusFormValues, Service } from "@src/types"; +import { defaultAnyRegion, defaultRentGpuService } from "@src/utils/sdl/data"; +import { useRouter } from "next/router"; +import sdlStore from "@src/store/sdlStore"; +import { useAtom } from "jotai"; +import { useProviderAttributesSchema } from "@src/queries/useProvidersQuery"; +import { makeStyles } from "tss-react/mui"; +import { useSdlDenoms } from "@src/hooks/useDenom"; +import { RegionSelect } from "./RegionSelect"; +import { AdvancedConfig } from "./AdvancedConfig"; +import { GpuFormControl } from "./GpuFormControl"; +import { CpuFormControl } from "./CpuFormControl"; +import { MemoryFormControl } from "./MemoryFormControl"; +import { StorageFormControl } from "./StorageFormControl"; +import { generateSdl } from "@src/utils/sdl/sdlGenerator"; +import { UrlService, handleDocClick } from "@src/utils/urlUtils"; +import { RouteStepKeys, defaultInitialDeposit } from "@src/utils/constants"; +import { deploymentData } from "@src/utils/deploymentData"; +import { useCertificate } from "@src/context/CertificateProvider"; +import { useSettings } from "@src/context/SettingsProvider"; +import { useWallet } from "@src/context/WalletProvider"; +import { validateDeploymentData } from "@src/utils/deploymentUtils"; +import { generateCertificate } from "@src/utils/certificateUtils"; +import { TransactionMessageData } from "@src/utils/TransactionMessageData"; +import { updateWallet } from "@src/utils/walletUtils"; +import { saveDeploymentManifestAndName } from "@src/utils/deploymentLocalDataUtils"; +import { DeploymentDepositModal } from "../deploymentDetail/DeploymentDepositModal"; +import { LinkTo } from "../shared/LinkTo"; +import { PrerequisiteList } from "../newDeploymentWizard/PrerequisiteList"; +import { ProviderAttributeSchemaDetailValue } from "@src/types/providerAttributes"; +import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; +import { ImageSelect } from "./ImageSelect"; +import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; +import { event } from "nextjs-google-analytics"; +import { AnalyticsEvents } from "@src/utils/analytics"; + +const yaml = require("js-yaml"); + +type Props = {}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + } +})); + +export const RentGpusForm: React.FunctionComponent = ({}) => { + const theme = useTheme(); + const { classes } = useStyles(); + const [error, setError] = useState(null); + // const [templateMetadata, setTemplateMetadata] = useState(null); + const [isCreatingDeployment, setIsCreatingDeployment] = useState(false); + const [isDepositingDeployment, setIsDepositingDeployment] = useState(false); + const [isCheckingPrerequisites, setIsCheckingPrerequisites] = useState(false); + const formRef = useRef(); + const [, setDeploySdl] = useAtom(sdlStore.deploySdl); + const [rentGpuSdl, setRentGpuSdl] = useAtom(sdlStore.rentGpuSdl); + const { data: providerAttributesSchema } = useProviderAttributesSchema(); + const { handleSubmit, control, watch, setValue, trigger } = useForm({ + defaultValues: { + services: [{ ...defaultRentGpuService }], + region: { ...defaultAnyRegion } + } + }); + const { services: _services, region: _region } = watch(); + const router = useRouter(); + const supportedSdlDenoms = useSdlDenoms(); + const currentService: Service = _services[0] || ({} as any); + const { settings } = useSettings(); + const { address, signAndBroadcastTx } = useWallet(); + const { loadValidCertificates, localCert, isLocalCertMatching, loadLocalCert, setSelectedCertificate } = useCertificate(); + const [sdlDenom, setSdlDenom] = useState("uakt"); + + useEffect(() => { + if (rentGpuSdl && rentGpuSdl.services) { + setValue("services", structuredClone(rentGpuSdl.services)); + setValue("region", rentGpuSdl.region || { ...defaultAnyRegion }); + + // Set the value of gpu models specifically because nested value doesn't re-render correctly + // https://github.com/react-hook-form/react-hook-form/issues/7758 + setValue("services.0.profile.gpuModels", rentGpuSdl.services[0].profile.gpuModels || []); + } + + const subscription = watch(({ services, region }) => { + setRentGpuSdl({ services: services as Service[], region: region as ProviderAttributeSchemaDetailValue }); + }); + return () => subscription.unsubscribe(); + }, []); + + async function createAndValidateDeploymentData(yamlStr, dseq = null, deposit = defaultInitialDeposit, depositorAddress = null) { + try { + if (!yamlStr) return null; + + const doc = yaml.load(yamlStr); + const dd = await deploymentData.NewDeploymentData(settings.apiEndpoint, doc, dseq, address, deposit, depositorAddress); + validateDeploymentData(dd); + + setSdlDenom(dd.deposit.denom); + + return dd; + } catch (err) { + console.error(err); + } + } + + const createAndValidateSdl = (yamlStr: string) => { + try { + if (!yamlStr) return null; + + const services = importSimpleSdl(yamlStr, providerAttributesSchema); + + setError(null); + + return services; + } catch (err) { + if (err.name === "YAMLException" || err.name === "CustomValidationError") { + setError(err.message); + } else if (err.name === "TemplateValidation") { + setError(err.message); + } else { + setError("Error while parsing SDL file"); + // setParsingError(err.message); + console.error(err); + } + } + }; + + const onSelectTemplate = (template: ApiTemplate) => { + const result = createAndValidateSdl(template?.deploy); + + if (!result) return; + + setValue("services", result as Service[]); + }; + + const onPrerequisiteContinue = () => { + setIsCheckingPrerequisites(false); + setIsDepositingDeployment(true); + }; + + const onDeploymentDeposit = async (deposit: number, depositorAddress: string) => { + setIsDepositingDeployment(false); + await handleCreateClick(deposit, depositorAddress); + }; + + const onSubmit = async (data: RentGpusFormValues) => { + setRentGpuSdl(data); + setIsCheckingPrerequisites(true); + }; + + async function handleCreateClick(deposit: number, depositorAddress: string) { + setError(null); + + try { + const sdl = generateSdl(rentGpuSdl.services, rentGpuSdl.region.key); + + setIsCreatingDeployment(true); + + const dd = await createAndValidateDeploymentData(sdl, null, deposit, depositorAddress); + const validCertificates = await loadValidCertificates(); + const currentCert = validCertificates.find(x => x.parsed === localCert?.certPem); + const isCertificateValidated = currentCert?.certificate?.state === "valid"; + const isLocalCertificateValidated = !!localCert && isLocalCertMatching; + + if (!dd) return; + + const messages = []; + const hasValidCert = isCertificateValidated && isLocalCertificateValidated; + let _crtpem: string; + let _encryptedKey: string; + + // Create a cert if the user doesn't have one + if (!hasValidCert) { + const { crtpem, pubpem, encryptedKey } = generateCertificate(address); + _crtpem = crtpem; + _encryptedKey = encryptedKey; + messages.push(TransactionMessageData.getCreateCertificateMsg(address, crtpem, pubpem)); + } + + messages.push(TransactionMessageData.getCreateDeploymentMsg(dd)); + const response = await signAndBroadcastTx(messages); + + if (response) { + // Set the new cert in storage + if (!hasValidCert) { + updateWallet(address, wallet => { + return { + ...wallet, + cert: _crtpem, + certKey: _encryptedKey + }; + }); + const validCerts = await loadValidCertificates(); + loadLocalCert(); + const currentCert = validCerts.find(x => x.parsed === _crtpem); + setSelectedCertificate(currentCert); + } + + setDeploySdl(null); + + // Save the manifest + saveDeploymentManifestAndName(dd.deploymentId.dseq, sdl, dd.version, address, currentService.image); + router.push(UrlService.newDeployment({ step: RouteStepKeys.createLeases, dseq: dd.deploymentId.dseq })); + + event(AnalyticsEvents.CREATE_GPU_DEPLOYMENT, { + category: "deployments", + label: "Create deployment rent gpu form" + }); + } else { + setIsCreatingDeployment(false); + } + } catch (error) { + setIsCreatingDeployment(false); + setError(error.message); + } + } + + return ( + <> + {isDepositingDeployment && ( + setIsDepositingDeployment(false)} + onDeploymentDeposit={onDeploymentDeposit} + min={5} // TODO Query from chain params + denom={sdlDenom} + infoText={ + + + To create a deployment, you need to have at least 5 AKT or 5 USDC in an escrow account.{" "} + handleDocClick(ev, "https://docs.akash.network/glossary/escrow#escrow-accounts")}> + Learn more. + + + + } + /> + )} + {isCheckingPrerequisites && setIsCheckingPrerequisites(false)} onContinue={onPrerequisiteContinue} />} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + Token + { + return ( + + ); + }} + /> + + + + + + + + {error && ( + + {error} + + )} + + {currentService?.env?.some(x => !!x.key && !x.value) && ( + + Some of the environment variables are empty. Please fill them in the advanced configuration before deploying. + + )} + + + + + + + + + ); +}; diff --git a/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx b/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx index baa74ee36..dfdf53f03 100644 --- a/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx +++ b/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx @@ -124,7 +124,7 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = ({}) => { setError(null); try { - const sdl = generateSdl(data); + const sdl = generateSdl(data.services); setDeploySdl({ title: "", @@ -157,7 +157,7 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = ({}) => { setError(null); try { - const sdl = generateSdl({ services: _services }); + const sdl = generateSdl(_services); setSdlResult(sdl); setIsPreviewingSdl(true); @@ -171,7 +171,7 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = ({}) => { }; const getTemplateData = () => { - const sdl = generateSdl({ services: _services }); + const sdl = generateSdl(_services); const template: Partial = { id: templateMetadata?.id || null, sdl, @@ -294,7 +294,6 @@ export const SimpleSDLBuilderForm: React.FunctionComponent = ({}) => { {services.map((service, serviceIndex) => ( ; @@ -87,7 +68,6 @@ const useStyles = makeStyles()(theme => ({ })); export const SimpleServiceFormControl: React.FunctionComponent = ({ - service, serviceIndex, control, _services, @@ -124,28 +104,30 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ return ( {/** Edit Environment Variables */} - setIsEditingEnv(null)} open={_isEditingEnv} serviceIndex={serviceIndex} envs={currentService.env} /> + {_isEditingEnv && setIsEditingEnv(null)} serviceIndex={serviceIndex} envs={currentService.env} />} {/** Edit Commands */} - setIsEditingCommands(null)} open={_isEditingCommands} serviceIndex={serviceIndex} /> + {_isEditingCommands && setIsEditingCommands(null)} serviceIndex={serviceIndex} />} {/** Edit Expose */} - setIsEditingExpose(null)} - open={_isEditingExpose} - serviceIndex={serviceIndex} - expose={currentService.expose} - services={_services} - providerAttributesSchema={providerAttributesSchema} - /> + {_isEditingExpose && ( + setIsEditingExpose(null)} + serviceIndex={serviceIndex} + expose={currentService.expose} + services={_services} + providerAttributesSchema={providerAttributesSchema} + /> + )} {/** Edit Placement */} - setIsEditingPlacement(null)} - open={_isEditingPlacement} - serviceIndex={serviceIndex} - services={_services} - placement={currentService.placement} - /> + {_isEditingPlacement && ( + setIsEditingPlacement(null)} + serviceIndex={serviceIndex} + services={_services} + placement={currentService.placement} + /> + )} = ({ )} - + - + @@ -309,738 +291,29 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ - { - if (!v) return "CPU amount is required."; - - const _value = v || 0; - - if (currentService.count === 1 && _value < 0.1) { - return "Minimum amount of CPU for a single service instance is 0.1."; - } else if (currentService.count === 1 && _value > 256) { - return "Maximum amount of CPU for a single service instance is 256."; - } else if (currentService.count > 1 && currentService.count * _value > 512) { - return "Maximum total amount of CPU for a single service instance group is 512."; - } - - return true; - } - }} - render={({ field, fieldState }) => ( - - - - - - CPU - - - The amount of vCPU's required for this workload. -
-
- The maximum for a single instance is 256 vCPU's. -
-
- The maximum total multiplied by the count of instances is 512 vCPU's. - - } - > - -
-
- - field.onChange(parseFloat(event.target.value))} - inputProps={{ min: 0.1, max: 256, step: 0.1 }} - size="small" - sx={{ width: "100px", marginTop: { xs: ".5rem", sm: 0 } }} - /> -
- - field.onChange(newValue)} - /> - - {!!fieldState.error && {fieldState.error.message}} -
-
- )} - /> +
- - { - if (!v) return "GPU amount is required."; - else if (v < 1) return "GPU amount must be greater than 0."; - return true; - } - }} - render={({ field, fieldState }) => ( - - - - - - GPU - - - The amount of GPUs required for this workload. -
-
- You can also specify the GPU vendor and model you want specifically. If you don't specify any model, providers with any - GPU model will bid on your workload. -
-
- - View official documentation. - - - } - > - -
-
- - ( - - )} - /> -
- - {currentService.profile.hasGpu && ( - - field.onChange(parseFloat(event.target.value))} - inputProps={{ min: 1, step: 1 }} - size="small" - sx={{ width: "100px" }} - /> - - )} -
- - {currentService.profile.hasGpu && ( - field.onChange(newValue)} - /> - )} - - {!!fieldState.error && {fieldState.error.message}} -
- )} - /> - - {currentService.profile.hasGpu && ( -
- - ( - - )} - /> - - - - {providerAttributesSchema ? ( - - ) : ( - - - - Loading GPU models... - - - )} - -
- )} -
+
- { - if (!v) return "Memory amount is required."; - - const currentUnit = memoryUnits.find(u => currentService.profile.ramUnit === u.suffix); - const _value = (v || 0) * currentUnit.value; - - if (currentService.count === 1 && _value < minMemory) { - return "Minimum amount of memory for a single service instance is 1 Mi."; - } else if (currentService.count === 1 && currentService.count * _value > maxMemory) { - return "Maximum amount of memory for a single service instance is 512 Gi."; - } else if (currentService.count > 1 && currentService.count * _value > maxGroupMemory) { - return "Maximum total amount of memory for a single service instance group is 1024 Gi."; - } - - return true; - } - }} - render={({ field, fieldState }) => ( - - - - - - Memory - - - The amount of memory required for this workload. -
-
- The maximum for a single instance is 512 Gi. -
-
- The maximum total multiplied by the count of instances is 1024 Gi. - - } - > - -
-
- - - field.onChange(parseFloat(event.target.value))} - inputProps={{ min: 1, step: 1 }} - size="small" - sx={{ width: "100px" }} - /> - - ( - - )} - /> - -
- - field.onChange(newValue)} - /> - - {!!fieldState.error && {fieldState.error.message}} -
-
- )} - /> +
- { - if (!v) return "Storage amount is required."; - - const currentUnit = storageUnits.find(u => currentService.profile.storageUnit === u.suffix); - const _value = (v || 0) * currentUnit.value; - - if (currentService.count * _value < minStorage) { - return "Minimum amount of storage for a single service instance is 5 Mi."; - } else if (currentService.count * _value > maxStorage) { - return "Maximum amount of storage for a single service instance is 32 Ti."; - } - - return true; - } - }} - name={`services.${serviceIndex}.profile.storage`} - render={({ field, fieldState }) => ( - - - - - - Ephemeral Storage - - - The amount of ephemeral disk storage required for this workload. -
-
- This disk storage is ephemeral, meaning it will be wiped out on every deployment update or provider reboot. -
-
- The maximum for a single instance is 32 Ti. -
-
- The maximum total multiplied by the count of instances is also 32 Ti. - - } - > - -
-
- - - field.onChange(parseFloat(event.target.value))} - inputProps={{ min: 1, step: 1 }} - size="small" - sx={{ width: "100px" }} - /> - - ( - - )} - /> - -
- - field.onChange(newValue)} - /> - - {!!fieldState.error && {fieldState.error.message}} -
-
- )} - /> +
- - { - if (!v) return "Storage amount is required."; - return true; - } - }} - render={({ field, fieldState }) => ( - - - - - - Persistent Storage - - - The amount of persistent storage required for this workload. -
-
- This storage is mounted on a persistent volume and persistent through the lifetime of the deployment -
-
- - View official documentation. - - - } - > - -
-
- - ( - - )} - /> -
- - {currentService.profile.hasPersistentStorage && ( - - field.onChange(parseFloat(event.target.value))} - inputProps={{ min: 1, step: 1 }} - size="small" - sx={{ width: "100px" }} - /> - - ( - - )} - /> - - )} -
- - {currentService.profile.hasPersistentStorage && ( - field.onChange(newValue)} - /> - )} - - {!!fieldState.error && {fieldState.error.message}} -
- )} - /> - - {currentService.profile.hasPersistentStorage && ( -
- - { - const hasValidChars = /^[a-z0-9\-]+$/.test(value); - const hasValidStartingChar = /^[a-z]/.test(value); - const hasValidEndingChar = !value.endsWith("-"); - - if (!hasValidChars) { - return "Invalid storage name. It must only be lower case letters, numbers and dashes."; - } else if (!hasValidStartingChar) { - return "Invalid starting character. It can only start with a lowercase letter."; - } else if (!hasValidEndingChar) { - return "Invalid ending character. It can only end with a lowercase letter or number"; - } - - return true; - } - }} - render={({ field, fieldState }) => ( - field.onChange(event.target.value)} - size="small" - sx={{ width: "100%" }} - helperText={!!fieldState.error && fieldState.error.message} - InputProps={{ - endAdornment: ( - - - The name of the persistent volume. -
-
- Multiple services can gain access to the same volume by name. - - } - > - -
-
- ) - }} - /> - )} - /> - - - Read only - - - ( - - )} - /> - -
- - ( - - Type - - - )} - /> - - ( - field.onChange(event.target.value)} - size="small" - sx={{ width: "100%", marginLeft: ".5rem" }} - helperText={!!fieldState.error && fieldState.error.message} - InputProps={{ - endAdornment: ( - - - The path to mount the persistent volume to. -
-
- Example: /mnt/data - - } - > - -
-
- ) - }} - /> - )} - /> -
-
- )} -
+
@@ -1048,153 +321,16 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ - - - - Environment Variables - - - - A list of environment variables to expose to the running container. -
-
- - View official documentation. - - - } - > - -
- - setIsEditingEnv(serviceIndex)}> - Edit - -
- - {currentService.env.length > 0 ? ( - currentService.env.map((e, i) => ( - -
- {e.key}= - - {e.value} - -
-
- )) - ) : ( - - None - - )} -
+
- - - - Commands - - - - Custom command use when executing container. -
-
- An example and popular use case is to run a bash script to install packages or run specific commands. - - } - > - -
- - setIsEditingCommands(serviceIndex)}> - Edit - -
- - {currentService.command.command.length > 0 ? ( - -
{currentService.command.command}
- {currentService.command.arg} -
- ) : ( - - None - - )} -
+
- - - - Expose - - - - Expose is a list of settings describing what can connect to the service. -
-
- - View official documentation. - - - } - > - -
- - setIsEditingExpose(serviceIndex)}> - Edit - -
- - {currentService.expose?.map((exp, i) => ( - -
- Port   - - {exp.port} : {exp.as} ({exp.proto}) - -
-
- Global   - {exp.global ? "True" : "False"} -
- {exp.ipName && ( -
- IP Name   - {exp.ipName} -
- )} -
- Accept   - - {exp.accept?.length > 0 - ? exp.accept?.map((a, i) => ( - - {a.value} - - )) - : "None"} - -
-
- ))} -
+
diff --git a/deploy-web/src/components/sdl/StorageFormControl.tsx b/deploy-web/src/components/sdl/StorageFormControl.tsx new file mode 100644 index 000000000..5df7c0d55 --- /dev/null +++ b/deploy-web/src/components/sdl/StorageFormControl.tsx @@ -0,0 +1,148 @@ +import { ReactNode } from "react"; +import { makeStyles } from "tss-react/mui"; +import { Box, FormControl, FormHelperText, MenuItem, Select, Slider, TextField, Typography, useTheme } from "@mui/material"; +import { RentGpusFormValues, SdlBuilderFormValues, Service } from "@src/types"; +import { CustomTooltip } from "../shared/CustomTooltip"; +import InfoIcon from "@mui/icons-material/Info"; +import { FormPaper } from "./FormPaper"; +import { Control, Controller } from "react-hook-form"; +import { cx } from "@emotion/css"; +import { validationConfig, storageUnits } from "../shared/akash/units"; +import StorageIcon from "@mui/icons-material/Storage"; + +type Props = { + serviceIndex: number; + children?: ReactNode; + control: Control; + currentService: Service; +}; + +const useStyles = makeStyles()(theme => ({ + formControl: { + marginBottom: theme.spacing(1.5) + }, + textField: { + width: "100%" + } +})); + +export const StorageFormControl: React.FunctionComponent = ({ control, serviceIndex, currentService }) => { + const { classes } = useStyles(); + const theme = useTheme(); + + return ( + { + if (!v) return "Storage amount is required."; + + const currentUnit = storageUnits.find(u => currentService.profile.storageUnit === u.suffix); + const _value = (v || 0) * currentUnit.value; + + if (currentService.count * _value < validationConfig.minStorage) { + return "Minimum amount of storage for a single service instance is 5 Mi."; + } else if (currentService.count * _value > validationConfig.maxStorage) { + return "Maximum amount of storage for a single service instance is 32 Ti."; + } + + return true; + } + }} + name={`services.${serviceIndex}.profile.storage`} + render={({ field, fieldState }) => ( + + + + + + Ephemeral Storage + + + The amount of ephemeral disk storage required for this workload. +
+
+ This disk storage is ephemeral, meaning it will be wiped out on every deployment update or provider reboot. +
+
+ The maximum for a single instance is 32 Ti. +
+
+ The maximum total multiplied by the count of instances is also 32 Ti. + + } + > + +
+
+ + + field.onChange(parseFloat(event.target.value))} + inputProps={{ min: 1, step: 1 }} + size="small" + sx={{ width: "100px" }} + /> + + ( + + )} + /> + +
+ + field.onChange(newValue)} + /> + + {!!fieldState.error && {fieldState.error.message}} +
+
+ )} + /> + ); +}; diff --git a/deploy-web/src/components/sdl/ToFormControl.tsx b/deploy-web/src/components/sdl/ToFormControl.tsx index 62a4e05d9..7cfc5f0fb 100644 --- a/deploy-web/src/components/sdl/ToFormControl.tsx +++ b/deploy-web/src/components/sdl/ToFormControl.tsx @@ -1,7 +1,7 @@ import { ReactNode, useImperativeHandle, forwardRef } from "react"; import { makeStyles } from "tss-react/mui"; import { Control, Controller, useFieldArray } from "react-hook-form"; -import { Box, Button, Checkbox, FormControlLabel, IconButton, MenuItem, Paper, Select, Typography, useTheme } from "@mui/material"; +import { Box, Button, IconButton, MenuItem, Paper, Select, Typography, useTheme } from "@mui/material"; import { SdlBuilderFormValues, Service } from "@src/types"; import DeleteIcon from "@mui/icons-material/Delete"; import { nanoid } from "nanoid"; @@ -86,21 +86,7 @@ export const ToFormControl = forwardRef(({ control, serviceInd
- - ( - } - label="Global" - /> - )} - /> - + {accept.map((acc, accIndex) => { diff --git a/deploy-web/src/components/shared/ExpandMore.tsx b/deploy-web/src/components/shared/ExpandMore.tsx index 2292bd762..b6e418d76 100644 --- a/deploy-web/src/components/shared/ExpandMore.tsx +++ b/deploy-web/src/components/shared/ExpandMore.tsx @@ -1,12 +1,32 @@ import { IconButton, IconButtonProps, styled } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -interface ExpandMoreProps extends IconButtonProps { +interface ExpandMoreButtonProps extends IconButtonProps { + expand: boolean; +} + +export const ExpandMoreButton = styled((props: ExpandMoreButtonProps) => { + const { expand, children, ...other } = props; + return ( + + + + ); +})(({ theme, expand }) => ({ + transform: !expand ? "rotate(0deg)" : "rotate(180deg)", + marginLeft: "auto", + transition: theme.transitions.create("transform", { + duration: theme.transitions.duration.shortest + }) +})); + +interface ExpandMoreProps { expand: boolean; } export const ExpandMore = styled((props: ExpandMoreProps) => { const { expand, ...other } = props; - return ; + return ; })(({ theme, expand }) => ({ transform: !expand ? "rotate(0deg)" : "rotate(180deg)", marginLeft: "auto", diff --git a/deploy-web/src/components/shared/akash/units.ts b/deploy-web/src/components/shared/akash/units.ts index babc8b670..70843a169 100644 --- a/deploy-web/src/components/shared/akash/units.ts +++ b/deploy-web/src/components/shared/akash/units.ts @@ -1,8 +1,15 @@ -export const minMemory = 1024; // 1 Mi -export const minStorage = 5 * 1024; // 5 Mi -export const maxMemory = 512 * 1024 ** 3; // 512 Gi -export const maxGroupMemory = 1024 * 1024 ** 3; // 1024 Gi -export const maxStorage = 32 * 1024 ** 4; // 32 Ti +// https://github.com/akash-network/akash-api/blob/ea71fbd0bee740198034bf1b0261c90baea88be0/go/node/deployment/v1beta3/validation_config.go +export const validationConfig = { + maxCpuAmount: 256, + maxGroupCpuCount: 512, + maxGpuAmount: 100, + maxGroupGpuCount: 512, + minMemory: 1024, // 1 Mi + minStorage: 5 * 1024, // 5 Mi + maxMemory: 512 * 1024 ** 3, // 512 Gi + maxGroupMemory: 1024 * 1024 ** 3, // 1024 Gi + maxStorage: 32 * 1024 ** 4 // 32 Ti +}; export const memoryUnits = [ { id: 3, suffix: "Mb", value: 1000 ** 2 }, diff --git a/deploy-web/src/components/user/UserProfileLayout.tsx b/deploy-web/src/components/user/UserProfileLayout.tsx index 7e2cc788c..6f89f2840 100644 --- a/deploy-web/src/components/user/UserProfileLayout.tsx +++ b/deploy-web/src/components/user/UserProfileLayout.tsx @@ -7,6 +7,7 @@ import { useRouter } from "next/router"; import { UrlService } from "@src/utils/urlUtils"; import { event } from "nextjs-google-analytics"; import { AnalyticsEvents } from "@src/utils/analytics"; +import { useCustomUser } from "@src/hooks/useCustomUser"; type UserProfileTab = "templates" | "favorites" | "address-book" | "settings"; type Props = { @@ -33,6 +34,7 @@ const useStyles = makeStyles()(theme => ({ export const UserProfileLayout: React.FunctionComponent = ({ page, children, username, bio }) => { const { classes } = useStyles(); const router = useRouter(); + const { user } = useCustomUser(); const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { switch (newValue) { @@ -87,42 +89,48 @@ export const UserProfileLayout: React.FunctionComponent = ({ page, childr }); }} /> - { - event(AnalyticsEvents.USER_PROFILE_FAVORITES_TAB, { - category: "profile", - label: "Click on favorites tab" - }); - }} - /> - { - event(AnalyticsEvents.USER_PROFILE_ADDRESS_BOOK_TAB, { - category: "profile", - label: "Click on address book tab" - }); - }} - /> - { - event(AnalyticsEvents.USER_PROFILE_SETTINGS_TAB, { - category: "profile", - label: "Click on settings tab" - }); - }} - /> + + {user?.username === username && ( + <> + {/** Only show favorites/address book/settings for current user */} + { + event(AnalyticsEvents.USER_PROFILE_FAVORITES_TAB, { + category: "profile", + label: "Click on favorites tab" + }); + }} + /> + { + event(AnalyticsEvents.USER_PROFILE_ADDRESS_BOOK_TAB, { + category: "profile", + label: "Click on address book tab" + }); + }} + /> + { + event(AnalyticsEvents.USER_PROFILE_SETTINGS_TAB, { + category: "profile", + label: "Click on settings tab" + }); + }} + /> + + )} diff --git a/deploy-web/src/components/wallet/GrantModal.tsx b/deploy-web/src/components/wallet/GrantModal.tsx index 4f5d8dd42..51030c270 100644 --- a/deploy-web/src/components/wallet/GrantModal.tsx +++ b/deploy-web/src/components/wallet/GrantModal.tsx @@ -16,6 +16,7 @@ import { denomToUdenom } from "@src/utils/mathHelpers"; import { useDenomData } from "@src/hooks/useWalletBalance"; import { uAktDenom } from "@src/utils/constants"; import { FormattedDate } from "react-intl"; +import { handleDocClick } from "@src/utils/urlUtils"; const useStyles = makeStyles()(theme => ({ formControl: { @@ -87,12 +88,6 @@ export const GrantModal: React.FunctionComponent = ({ editingGrant, addre } }; - function handleDocClick(ev, url: string) { - ev.preventDefault(); - - window.open(url, "_blank"); - } - const onBalanceClick = () => { clearErrors(); setValue("amount", denomData?.inputMax); @@ -257,4 +252,3 @@ export const GrantModal: React.FunctionComponent = ({ editingGrant, addre ); }; - diff --git a/deploy-web/src/context/TemplatesProvider/TemplatesProviderContext.tsx b/deploy-web/src/context/TemplatesProvider/TemplatesProviderContext.tsx index a0dbd5b94..e9cef55f4 100644 --- a/deploy-web/src/context/TemplatesProvider/TemplatesProviderContext.tsx +++ b/deploy-web/src/context/TemplatesProvider/TemplatesProviderContext.tsx @@ -26,3 +26,4 @@ export const TemplatesProvider = ({ children }) => { export const useTemplates = () => { return { ...React.useContext(TemplatesProviderContext) }; }; + diff --git a/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/deploy-web/src/context/WalletProvider/WalletProvider.tsx index b44c0bd03..f07638ab2 100644 --- a/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -78,8 +78,6 @@ export const WalletProvider = ({ children }) => { const usdcIbcDenom = useUsdcDenom(); useEffect(() => { - console.log("useWallet on mount"); - if (document.readyState === "complete") { setIsWindowLoaded(true); } else { diff --git a/deploy-web/src/hooks/useDenom.ts b/deploy-web/src/hooks/useDenom.ts index 83b458865..9e63cbbd4 100644 --- a/deploy-web/src/hooks/useDenom.ts +++ b/deploy-web/src/hooks/useDenom.ts @@ -15,7 +15,7 @@ export const useSdlDenoms = () => { const usdcDenom = useUsdcDenom(); return [ - { id: "uakt", label: "uAKT", value: "uakt" }, - { id: "uusdc", label: "uUSDC", value: usdcDenom } + { id: "uakt", label: "uAKT", tokenLabel: "AKT", value: "uakt" }, + { id: "uusdc", label: "uUSDC", tokenLabel: "USDC", value: usdcDenom } ]; }; diff --git a/deploy-web/src/hooks/useGpuTemplates.ts b/deploy-web/src/hooks/useGpuTemplates.ts new file mode 100644 index 000000000..8befd8098 --- /dev/null +++ b/deploy-web/src/hooks/useGpuTemplates.ts @@ -0,0 +1,19 @@ +import { useTemplates } from "@src/context/TemplatesProvider"; + +const yaml = require("js-yaml"); + +export const useGpuTemplates = () => { + const { isLoading: isLoadingTemplates, categories } = useTemplates(); + let gpuTemplates = categories?.find(x => x.title === "AI - GPU")?.templates || []; + + gpuTemplates = gpuTemplates.map(x => { + const templateSdl = yaml.load(x.deploy); + + return { + ...x, + image: templateSdl.services[Object.keys(templateSdl.services)[0]].image + }; + }); + + return { isLoadingTemplates, gpuTemplates }; +}; diff --git a/deploy-web/src/pages/new-deployment/index.tsx b/deploy-web/src/pages/new-deployment/index.tsx index 2f9122583..ae1881917 100644 --- a/deploy-web/src/pages/new-deployment/index.tsx +++ b/deploy-web/src/pages/new-deployment/index.tsx @@ -36,7 +36,7 @@ const useStyles = makeStyles()(theme => ({ const NewDeploymentPage: React.FunctionComponent = ({}) => { const { classes } = useStyles(); const { isLoading: isLoadingTemplates, templates } = useTemplates(); - const [activeStep, setActiveStep] = useState(0); + const [activeStep, setActiveStep] = useState(null); const [selectedTemplate, setSelectedTemplate] = useState(null); const deploySdl = useAtomValue(sdlStore.deploySdl); const [editedManifest, setEditedManifest] = useState(null); @@ -143,7 +143,7 @@ const NewDeploymentPage: React.FunctionComponent = ({}) => { - + {activeStep !== null && }
{activeStep === 0 && } diff --git a/deploy-web/src/pages/rent-gpu/index.tsx b/deploy-web/src/pages/rent-gpu/index.tsx new file mode 100644 index 000000000..563dc75fb --- /dev/null +++ b/deploy-web/src/pages/rent-gpu/index.tsx @@ -0,0 +1,41 @@ +import Layout from "@src/components/layout/Layout"; +import { Title } from "@src/components/shared/Title"; +import PageContainer from "@src/components/shared/PageContainer"; +import { Typography } from "@mui/material"; +import { CustomNextSeo } from "@src/components/shared/CustomNextSeo"; +import React from "react"; +import { UrlService } from "@src/utils/urlUtils"; +import { RentGpusForm } from "@src/components/sdl/RentGpusForm"; + +type Props = {}; + +const RentGpuPage: React.FunctionComponent = ({}) => { + return ( + + + + + Rent GPUs</>} /> + + <Typography variant="body1" color="textSecondary" sx={{ marginBottom: "2rem" }}> + Deploy any AI workload on a wide variety of Nvidia GPU models. Select from one of the available templates or input your own docker container image to + deploy on one of the providers available worldwide on the network. + </Typography> + + <RentGpusForm /> + </PageContainer> + </Layout> + ); +}; + +export default RentGpuPage; + +export async function getServerSideProps({ params }) { + return { + props: {} + }; +} diff --git a/deploy-web/src/pages/templates/index.tsx b/deploy-web/src/pages/templates/index.tsx index 580595590..73da6688b 100644 --- a/deploy-web/src/pages/templates/index.tsx +++ b/deploy-web/src/pages/templates/index.tsx @@ -184,11 +184,12 @@ const TemplateGalleryPage: React.FunctionComponent<Props> = ({}) => { </Box> <Box sx={{ display: "flex" }}> - {!isLoadingTemplates && templates.length > 0 && ( + {templates.length > 0 && ( <Box sx={{ width: "222px", marginRight: "3rem", display: { xs: "none", sm: "none", md: "block" } }}> <Typography variant="body1" sx={{ marginBottom: "1rem", fontWeight: "bold" }}> Filter Templates </Typography> + {searchBar} <List> diff --git a/deploy-web/src/queries/queryKeys.ts b/deploy-web/src/queries/queryKeys.ts index 8fa4624ea..f004243b5 100644 --- a/deploy-web/src/queries/queryKeys.ts +++ b/deploy-web/src/queries/queryKeys.ts @@ -34,6 +34,7 @@ export class QueryKeys { static getBidListKey = (address: string, dseq: string) => ["BID_LIST", address, dseq]; static getProvidersKey = () => ["PROVIDERS"]; static getProviderListKey = () => ["PROVIDER_LIST"]; + static getProviderRegionsKey = () => ["PROVIDER_REGIONS"]; static getProviderDetailKey = (owner: string) => ["PROVIDERS", owner]; static getDataNodeProvidersKey = () => ["DATA_NODE_PROVIDERS"]; static getProviderStatusKey = (providerUri: string) => ["PROVIDER_STATUS", providerUri]; diff --git a/deploy-web/src/queries/useProvidersQuery.ts b/deploy-web/src/queries/useProvidersQuery.ts index fd839e863..ec35a3f26 100644 --- a/deploy-web/src/queries/useProvidersQuery.ts +++ b/deploy-web/src/queries/useProvidersQuery.ts @@ -4,7 +4,7 @@ import axios, { AxiosResponse } from "axios"; import { ApiUrlService } from "@src/utils/apiUtils"; import { getNetworkCapacityDto, providerStatusToDto } from "@src/utils/providerUtils"; import { PROVIDER_PROXY_URL } from "@src/utils/constants"; -import { ApiProviderDetail, ApiProviderList, Auditor } from "@src/types/provider"; +import { ApiProviderDetail, ApiProviderList, ApiProviderRegion, Auditor } from "@src/types/provider"; import { ProviderAttributesSchema } from "@src/types/providerAttributes"; async function getProviderDetail(owner: string): Promise<ApiProviderDetail> { @@ -101,3 +101,13 @@ async function getProviderList(): Promise<Array<ApiProviderList>> { export function useProviderList(options = {}) { return useQuery(QueryKeys.getProviderListKey(), () => getProviderList(), options); } + +async function getProviderRegions(): Promise<Array<ApiProviderRegion>> { + const response = await axios.get(ApiUrlService.providerRegions()); + + return response.data; +} + +export function useProviderRegions(options = {}) { + return useQuery(QueryKeys.getProviderRegionsKey(), () => getProviderRegions(), options); +} diff --git a/deploy-web/src/store/sdlStore.ts b/deploy-web/src/store/sdlStore.ts index 9f1c68bb5..2a9e5d3b3 100644 --- a/deploy-web/src/store/sdlStore.ts +++ b/deploy-web/src/store/sdlStore.ts @@ -1,12 +1,14 @@ -import { SdlBuilderFormValues, TemplateCreation } from "@src/types"; +import { RentGpusFormValues, SdlBuilderFormValues, TemplateCreation } from "@src/types"; import { atom } from "jotai"; const deploySdl = atom<TemplateCreation>(null as TemplateCreation); const sdlBuilderSdl = atom<SdlBuilderFormValues>(null as SdlBuilderFormValues); +const rentGpuSdl = atom<RentGpusFormValues>(null as RentGpusFormValues); const selectedSdlEditMode = atom<"yaml" | "builder">("yaml"); export default { deploySdl, sdlBuilderSdl, + rentGpuSdl, selectedSdlEditMode }; diff --git a/deploy-web/src/types/provider.ts b/deploy-web/src/types/provider.ts index 9696f2d63..4e131c5be 100644 --- a/deploy-web/src/types/provider.ts +++ b/deploy-web/src/types/provider.ts @@ -284,3 +284,9 @@ export type Auditor = { address: string; website: string; }; + +export interface ApiProviderRegion { + key: string; + description: string; + providers: string[]; +} diff --git a/deploy-web/src/types/providerAttributes.ts b/deploy-web/src/types/providerAttributes.ts index 230c51b17..563a5bf16 100644 --- a/deploy-web/src/types/providerAttributes.ts +++ b/deploy-web/src/types/providerAttributes.ts @@ -70,4 +70,11 @@ export type ProviderAttributeSchemaDetail = { values?: Array<ProviderAttributeSchemaDetailValue>; }; -export type ProviderAttributeSchemaDetailValue = { key: string; description: string; value?: any }; +export interface ProviderAttributeSchemaDetailValue { + key: string; + description: string; + value?: any; +} +export interface ProviderRegionValue extends ProviderAttributeSchemaDetailValue { + providers: string[]; +} diff --git a/deploy-web/src/types/sdlBuilder.ts b/deploy-web/src/types/sdlBuilder.ts index a4b630c5d..6af22dfb6 100644 --- a/deploy-web/src/types/sdlBuilder.ts +++ b/deploy-web/src/types/sdlBuilder.ts @@ -1,4 +1,4 @@ -import { ProviderAttributeSchemaDetailValue } from "./providerAttributes"; +import { ProviderAttributeSchemaDetailValue, ProviderRegionValue } from "./providerAttributes"; export type Service = { id: string; @@ -142,3 +142,8 @@ export type SdlSaveTemplateFormValues = { title: string; visibility: string; }; + +export type RentGpusFormValues = { + services: Service[]; + region: Partial<ProviderRegionValue>; +}; diff --git a/deploy-web/src/utils/analytics.ts b/deploy-web/src/utils/analytics.ts index 3eee5e96c..da6196450 100644 --- a/deploy-web/src/utils/analytics.ts +++ b/deploy-web/src/utils/analytics.ts @@ -17,6 +17,7 @@ export enum AnalyticsEvents { CREATE_LEASE = "create_lease", SEND_MANIFEST = "send_manifest", CREATE_DEPLOYMENT = "create_deployment", + CREATE_GPU_DEPLOYMENT = "create_gpu_deployment", AUTHORIZE_SPEND = "authorize_spend", NAVIGATE_TAB = "navigate_tab_", // Append tab diff --git a/deploy-web/src/utils/apiUtils.ts b/deploy-web/src/utils/apiUtils.ts index 8a0e09548..1c56d44b1 100644 --- a/deploy-web/src/utils/apiUtils.ts +++ b/deploy-web/src/utils/apiUtils.ts @@ -24,6 +24,9 @@ export class ApiUrlService { static providerDetail(owner: string) { return `${BASE_API_URL}/providers/${owner}`; } + static providerRegions() { + return `${BASE_API_URL}/provider-regions`; + } static block(apiEndpoint: string, id: string) { return `${apiEndpoint}/blocks/${id}`; } diff --git a/deploy-web/src/utils/deploymentUtils.ts b/deploy-web/src/utils/deploymentUtils.ts index 24a846143..241f6d6c8 100644 --- a/deploy-web/src/utils/deploymentUtils.ts +++ b/deploy-web/src/utils/deploymentUtils.ts @@ -45,3 +45,27 @@ export const sendManifestToProvider = async (providerInfo: ApiProviderList, mani return response; }; + +/** + * Validate values to change in the template + */ +export function validateDeploymentData(deploymentData, selectedTemplate?) { + if (selectedTemplate?.valuesToChange) { + for (const valueToChange of selectedTemplate.valuesToChange) { + if (valueToChange.field === "accept" || valueToChange.field === "env") { + const serviceNames = Object.keys(deploymentData.sdl.services); + for (const serviceName of serviceNames) { + if ( + deploymentData.sdl.services[serviceName].expose?.some(e => e.accept?.includes(valueToChange.initialValue)) || + deploymentData.sdl.services[serviceName].env?.some(e => e?.includes(valueToChange.initialValue)) + ) { + let error = new Error(`Template value of "${valueToChange.initialValue}" needs to be changed`); + error.name = "TemplateValidation"; + + throw error; + } + } + } + } + } +} diff --git a/deploy-web/src/utils/sdl/data.ts b/deploy-web/src/utils/sdl/data.ts index 25306dc89..38f1df88e 100644 --- a/deploy-web/src/utils/sdl/data.ts +++ b/deploy-web/src/utils/sdl/data.ts @@ -76,6 +76,72 @@ export const defaultService: Service = { count: 1 }; +export const defaultRentGpuService: Service = { + id: nanoid(), + title: "service-1", + image: "", + profile: { + cpu: 0.1, + gpu: 1, + gpuVendor: "nvidia", + gpuModels: [], + hasGpu: true, + ram: 512, + ramUnit: "Mi", + storage: 1, + storageUnit: "Gi", + hasPersistentStorage: false, + persistentStorage: 10, + persistentStorageUnit: "Gi", + persistentStorageParam: { + name: "data", + type: "beta2", + mount: "" + } + }, + expose: [ + { + id: nanoid(), + port: 80, + as: 80, + proto: "http", + global: true, + to: [], + accept: [], + ipName: "", + httpOptions: { + maxBodySize: defaultHttpOptions.maxBodySize, + readTimeout: defaultHttpOptions.readTimeout, + sendTimeout: defaultHttpOptions.sendTimeout, + nextCases: defaultHttpOptions.nextCases, + nextTries: defaultHttpOptions.nextTries, + nextTimeout: defaultHttpOptions.nextTimeout + } + } + ], + command: { command: "", arg: "" }, + env: [], + placement: { + name: "dcloud", + pricing: { + amount: 1000, + denom: "uakt" + }, + signedBy: { + anyOf: [], + allOf: [] + }, + attributes: [] + }, + count: 1 +}; + +export const defaultAnyRegion = { + key: "any", + value: "any", + description: "Any region" +} + export const nextCases = [ { id: 1, value: "error" }, { id: 2, value: "timeout" }, diff --git a/deploy-web/src/utils/sdl/sdlGenerator.ts b/deploy-web/src/utils/sdl/sdlGenerator.ts index eb092445a..c9124835d 100644 --- a/deploy-web/src/utils/sdl/sdlGenerator.ts +++ b/deploy-web/src/utils/sdl/sdlGenerator.ts @@ -1,11 +1,11 @@ -import { Expose, SdlBuilderFormValues } from "@src/types"; +import { Expose, Service } from "@src/types"; import yaml from "js-yaml"; import { defaultHttpOptions } from "./data"; -export const generateSdl = (formData: SdlBuilderFormValues) => { +export const generateSdl = (services: Service[], region?: string) => { const sdl = { version: "2.0", services: {}, profiles: { compute: {}, placement: {} }, deployment: {} }; - formData.services.forEach(service => { + services.forEach(service => { sdl.services[service.title] = { image: service.image, @@ -150,6 +150,14 @@ export const generateSdl = (formData: SdlBuilderFormValues) => { sdl.profiles.placement[service.placement.name].attributes = service.placement.attributes.reduce((acc, curr) => ((acc[curr.key] = curr.value), acc), {}); } + // Regions + if (!!region && region !== "any") { + sdl.profiles.placement[service.placement.name].attributes = { + ...(sdl.profiles.placement[service.placement.name].attributes || {}), + "location-region": region.toLowerCase() + }; + } + // IP Lease if (service.expose.some(exp => exp.ipName)) { sdl["endpoints"] = {}; diff --git a/deploy-web/src/utils/templates.ts b/deploy-web/src/utils/templates.ts index f9c4c0e73..42aa0876f 100644 --- a/deploy-web/src/utils/templates.ts +++ b/deploy-web/src/utils/templates.ts @@ -5,13 +5,6 @@ export const sdlBuilderTemplate = { description: "An empty template with some basic config to get started.", content: "# Paste your SDL here!" }; -export const emptyTemplate = { - title: "Empty", - code: "empty", - category: "General", - description: "An empty template with some basic config to get started.", - content: "" -}; export const helloWorldTemplate = { title: "Hello World", name: "Hello World", @@ -140,4 +133,4 @@ deployment: ` }; -export const hardcodedTemplates = [sdlBuilderTemplate, emptyTemplate, helloWorldTemplate, ubuntuTemplate]; +export const hardcodedTemplates = [sdlBuilderTemplate, helloWorldTemplate, ubuntuTemplate]; diff --git a/deploy-web/src/utils/urlUtils.ts b/deploy-web/src/utils/urlUtils.ts index d2d551704..51423ddd7 100644 --- a/deploy-web/src/utils/urlUtils.ts +++ b/deploy-web/src/utils/urlUtils.ts @@ -24,6 +24,7 @@ export class UrlService { static getStartedWallet = (section?: string) => `/get-started/wallet${appendSearchParams({ section })}`; static sdlBuilder = (id?: string) => `/sdl-builder${appendSearchParams({ id })}`; + static rentGpus = () => `/rent-gpu`; static priceCompare = () => "/price-compare"; static analytics = () => "/analytics"; static graph = (snapshot: string) => `/graph/${snapshot}`; @@ -119,3 +120,9 @@ export function isValidHttpUrl(str: string): boolean { return url.protocol === "http:" || url.protocol === "https:"; } + +export function handleDocClick(ev, url) { + ev.preventDefault(); + + window.open(url, "_blank"); +}