diff --git a/package-lock.json b/package-lock.json index 84621699..809001c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "deeploy-dapp", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deeploy-dapp", - "version": "0.0.1", + "version": "0.1.0", "dependencies": { + "@codemirror/autocomplete": "^6.19.0", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lint": "^6.9.0", "@heroui/alert": "^2.2.23", "@heroui/button": "^2.2.23", "@heroui/checkbox": "^2.3.24", @@ -28,6 +31,7 @@ "@hookform/resolvers": "^5.1.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.80.6", + "@uiw/react-codemirror": "^4.25.2", "axios": "^1.9.0", "clsx": "^2.1.1", "connectkit": "^1.8.2", @@ -504,6 +508,109 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.0.tgz", + "integrity": "sha512-61Hfv3cF07XvUxNeC3E7jhG8XNi1Yom1G0lRC936oLnlF+jrbrv8rc/J98XlYzcsAoTVupfsf5fLej1aI8kyIg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.9.0.tgz", + "integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.0.tgz", + "integrity": "sha512-wZxW+9XDytH3SKvS8cQzMyQCaaazH8XL1EMHleHe00wVzsv7NBQKVW2yzEHrRhmM7ZOhVdItPbvlRBvMp9ej7A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@coinbase/wallet-sdk": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@coinbase/wallet-sdk/-/wallet-sdk-4.3.6.tgz", @@ -4206,6 +4313,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", + "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.2.tgz", + "integrity": "sha512-z8TQwaBXXQIvG6i2g3e9cgMwUUXu9Ib7jo2qRRggdhwKpM56Dw3PM3wmexn+EGaaOZ7az0K7sjc3/gcGW7sz7A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", @@ -4221,6 +4363,12 @@ "@lit-labs/ssr-dom-shim": "^1.4.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@metamask/eth-json-rpc-provider": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@metamask/eth-json-rpc-provider/-/eth-json-rpc-provider-1.0.1.tgz", @@ -9113,6 +9261,59 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.2.tgz", + "integrity": "sha512-s2fbpdXrSMWEc86moll/d007ZFhu6jzwNu5cWv/2o7egymvLeZO52LWkewgbr+BUCGWGPsoJVWeaejbsb/hLcw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.2.tgz", + "integrity": "sha512-XP3R1xyE0CP6Q0iR0xf3ed+cJzJnfmbLelgJR6osVVtMStGGZP3pGQjjwDRYptmjGHfEELUyyBLdY25h0BQg7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.2", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -10933,6 +11134,21 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -11109,6 +11325,12 @@ "node": ">=0.8" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -14758,6 +14980,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-value-types": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", @@ -15459,6 +15687,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wagmi": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.16.0.tgz", diff --git a/package.json b/package.json index 61e2429c..68bc3a67 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/autocomplete": "^6.19.0", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lint": "^6.9.0", "@heroui/alert": "^2.2.23", "@heroui/button": "^2.2.23", "@heroui/checkbox": "^2.3.24", @@ -31,6 +34,7 @@ "@hookform/resolvers": "^5.1.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.80.6", + "@uiw/react-codemirror": "^4.25.2", "axios": "^1.9.0", "clsx": "^2.1.1", "connectkit": "^1.8.2", diff --git a/src/components/account/Overview.tsx b/src/components/account/Overview.tsx deleted file mode 100644 index 1f653575..00000000 --- a/src/components/account/Overview.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Button } from '@heroui/button'; -import { config } from '@lib/config'; -import { BlockchainContextType, useBlockchainContext } from '@lib/contexts/blockchain'; -import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; -import { routePath } from '@lib/routes/route-paths'; -import { fBI } from '@lib/utils'; -import { CardWithHeader } from '@shared/cards/CardWithHeader'; -import { ValueWithLabel } from '@shared/ValueWithLabel'; -import _ from 'lodash'; -import { useEffect, useState } from 'react'; -import { RiBox3Line, RiDiscountPercentLine, RiMoneyDollarCircleLine } from 'react-icons/ri'; -import { Link } from 'react-router-dom'; - -function Overview() { - const { fetchErc20Balance } = useBlockchainContext() as BlockchainContextType; - const { apps } = useDeploymentContext() as DeploymentContextType; - - const [usdcBalance, setUsdcBalance] = useState(0n); - const [projectsCount, setProjectsCount] = useState(0); - - // Init - useEffect(() => { - fetchErc20Balance(config.usdcContractAddress).then((balance) => { - setUsdcBalance(balance); - }); - }, []); - - useEffect(() => { - console.log('[Overview] Apps', apps); - - const uniqueProjectHashes = _(Object.values(apps)) - .map((app) => { - return Object.values(app); - }) - .flatten() - .map((value) => { - return value.deeploy_specs.project_id; - }) - .uniq() - .value(); - - setProjectsCount(uniqueProjectHashes.length); - }, [apps]); - - return ( -
-
- } title="Balance"> -
- - - -
-
- - } title="Projects"> -
- 1 ? 's' : ''} running`} value={projectsCount} /> - - -
-
- - } title="Offers Available"> -
- - - -
-
-
-
- ); -} - -export default Overview; diff --git a/src/components/account/BillingMonthSelect.tsx b/src/components/account/invoicing/BillingMonthSelect.tsx similarity index 100% rename from src/components/account/BillingMonthSelect.tsx rename to src/components/account/invoicing/BillingMonthSelect.tsx diff --git a/src/components/account/DraftInvoiceCard.tsx b/src/components/account/invoicing/DraftInvoiceCard.tsx similarity index 100% rename from src/components/account/DraftInvoiceCard.tsx rename to src/components/account/invoicing/DraftInvoiceCard.tsx diff --git a/src/components/account/Invoicing.tsx b/src/components/account/invoicing/Invoicing.tsx similarity index 100% rename from src/components/account/Invoicing.tsx rename to src/components/account/invoicing/Invoicing.tsx diff --git a/src/components/account/profile/EditPublicProfile.tsx b/src/components/account/profile/EditPublicProfile.tsx new file mode 100644 index 00000000..692e3a9a --- /dev/null +++ b/src/components/account/profile/EditPublicProfile.tsx @@ -0,0 +1,133 @@ +import { Button } from '@heroui/button'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { updatePublicProfileInfo } from '@lib/api/backend'; +import { buildPublicProfileSchema } from '@schemas/profile'; +import InputWithLabel from '@shared/InputWithLabel'; +import Label from '@shared/Label'; +import StyledInput from '@shared/StyledInput'; +import { BRANDING_PLATFORM_NAMES, PublicProfileInfo } from '@typedefs/general'; +import { useMemo, useState } from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import z from 'zod'; + +export default function EditPublicProfile({ + profileInfo, + brandingPlatforms, + setEditing, + onEdit, +}: { + profileInfo: PublicProfileInfo; + brandingPlatforms: string[]; + setEditing: (editing: boolean) => void; + onEdit: () => void; +}) { + const schema = useMemo(() => buildPublicProfileSchema(brandingPlatforms), [brandingPlatforms]); + type FormValues = z.infer; + + const [isLoading, setLoading] = useState(false); + + const defaultValues: FormValues = useMemo(() => { + const emptyLinks = brandingPlatforms.reduce( + (acc, platform) => { + acc[platform] = ''; + return acc; + }, + {} as Record, + ); + + const values = { + name: profileInfo.name ?? '', + description: profileInfo.description ?? '', + links: { + ...emptyLinks, + ...profileInfo.links, + }, + }; + + return values; + }, [profileInfo, brandingPlatforms]); + + const form = useForm({ + resolver: zodResolver(schema), + mode: 'onTouched', + defaultValues, + shouldUnregister: true, + }); + + const { control } = form; + + const onSubmit = async (data: FormValues) => { + console.log(data); + setLoading(true); + + try { + await updatePublicProfileInfo(data); + toast.success('Public profile updated successfully.'); + onEdit(); + } catch (error) { + console.error('Error updating public profile info.'); + } finally { + setLoading(false); + } + }; + + const onError = (errors: any) => { + console.log('Validation errors:', errors); + }; + + return ( + +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/account/profile/ImageUpload.tsx b/src/components/account/profile/ImageUpload.tsx new file mode 100644 index 00000000..69d77874 --- /dev/null +++ b/src/components/account/profile/ImageUpload.tsx @@ -0,0 +1,91 @@ +import { Button } from '@heroui/button'; +import { uploadProfileImage } from '@lib/api/backend'; +import { resizeImage } from '@lib/utils'; +import { useCallback, useRef } from 'react'; +import toast from 'react-hot-toast'; + +const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png']; + +export default function ImageUpload({ + onSuccessfulUpload, + setImageLoading, +}: { + onSuccessfulUpload: () => void; + setImageLoading: (loading: boolean) => void; +}) { + const inputRef = useRef(null); + + const handleFileChange = useCallback( + async (event: React.ChangeEvent) => { + setImageLoading(true); + + let file: File | undefined = event.target.files?.[0]; + + if (!file) { + return; + } + + // Check if file extension is allowed + const fileName = file.name.toLowerCase(); + const fileExtension = fileName.substring(fileName.lastIndexOf('.')); + + if (!ALLOWED_IMAGE_EXTENSIONS.includes(fileExtension)) { + toast.error('Only .jpg, .jpeg, and .png images are allowed.'); + setImageLoading(false); + event.target.value = ''; + return; + } + + if (file.size > 50_000) { + const resizedBlob = await resizeImage(file); + console.log('Resized size (KB):', resizedBlob.size / 1024); + file = new File([resizedBlob], file.name, { type: 'image/jpeg' }); + } + + try { + await uploadProfileImage(file); + onSuccessfulUpload(); + + // Takes into account the response time of the image request + setTimeout(() => { + toast.success('Profile image updated successfully.'); + }, 500); + } catch (err) { + console.error('Profile image upload failed:', err); + toast.error('Failed to upload profile image.'); + setImageLoading(false); + } finally { + // Reset the input so the same file can be uploaded twice in a row if needed + event.target.value = ''; + } + }, + [onSuccessfulUpload, setImageLoading], + ); + + const handleButtonClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + return ( +
+ + + +
+ ); +} diff --git a/src/components/account/profile/Profile.tsx b/src/components/account/profile/Profile.tsx new file mode 100644 index 00000000..c5e0a79b --- /dev/null +++ b/src/components/account/profile/Profile.tsx @@ -0,0 +1,17 @@ +import ProfileSection from './ProfileSection'; +import PublicProfile from './PublicProfile'; +import WalletInformation from './WalletInformation'; + +export default function Profile() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/components/account/profile/ProfileSection.tsx b/src/components/account/profile/ProfileSection.tsx new file mode 100644 index 00000000..149955d9 --- /dev/null +++ b/src/components/account/profile/ProfileSection.tsx @@ -0,0 +1,8 @@ +export default function ProfileSection({ title, children }: { title: React.ReactNode; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} diff --git a/src/components/account/profile/ProfileSectionWrapper.tsx b/src/components/account/profile/ProfileSectionWrapper.tsx new file mode 100644 index 00000000..e8f5b4e6 --- /dev/null +++ b/src/components/account/profile/ProfileSectionWrapper.tsx @@ -0,0 +1,9 @@ +import { BorderedCard } from '@shared/cards/BorderedCard'; + +export default function ProfileSectionWrapper({ children }: { children: React.ReactNode }) { + return ( + +
{children}
+
+ ); +} diff --git a/src/components/account/profile/PublicProfile.tsx b/src/components/account/profile/PublicProfile.tsx new file mode 100644 index 00000000..e2b835f3 --- /dev/null +++ b/src/components/account/profile/PublicProfile.tsx @@ -0,0 +1,235 @@ +import { Button } from '@heroui/button'; +import { Skeleton } from '@heroui/skeleton'; +import { getBrandingPlatforms, getPublicProfileInfo } from '@lib/api/backend'; +import { config, getDevAddress, isUsingDevAddress } from '@lib/config'; +import { DetailsCard } from '@shared/cards/DetailsCard'; +import ProfileRow from '@shared/ProfileRow'; +import { useQuery } from '@tanstack/react-query'; +import { EthAddress } from '@typedefs/blockchain'; +import { BRANDING_PLATFORM_NAMES, PublicProfileInfo } from '@typedefs/general'; +import clsx from 'clsx'; +import { useEffect, useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { HiUser } from 'react-icons/hi'; +import { useAccount } from 'wagmi'; +import EditPublicProfile from './EditPublicProfile'; +import ImageUpload from './ImageUpload'; +import ProfileSectionWrapper from './ProfileSectionWrapper'; + +const EMPTY_PROFILE: PublicProfileInfo = { + name: '', + description: '', + links: {}, +}; + +export default function PublicProfile() { + const [isImageLoading, setImageLoading] = useState(true); + const [imageError, setImageError] = useState(false); + const [imageRefreshToken, setImageRefreshToken] = useState(0); + + const [isEditing, setEditing] = useState(false); + + const { address } = isUsingDevAddress ? getDevAddress() : useAccount(); + + const { data: brandingPlatforms = [], isLoading: isLoadingBrandingPlatforms } = useQuery({ + queryKey: ['brandingPlatforms'], + queryFn: async () => { + try { + const response = await getBrandingPlatforms(); + + if (!response) { + console.log('Error fetching branding platforms.'); + } + + return response ?? []; + } catch (error) { + console.error('Error fetching branding platforms.'); + throw error; + } + }, + retry: false, + refetchOnWindowFocus: false, + }); + + const { + data: profileInfo = EMPTY_PROFILE, + isLoading: isLoadingProfileInfo, + isFetching: isFetchingProfileInfo, + isError: isPublicProfileError, + refetch: refetchPublicProfileInfo, + } = useQuery({ + queryKey: ['publicProfile', address], + queryFn: async () => { + if (!address) { + return EMPTY_PROFILE; + } + + try { + const response = await getPublicProfileInfo(address as EthAddress); + + if (!response || !response?.brands?.[0] || response?.brands?.[0]?.links === undefined) { + console.log('Error fetching public profile info.'); + } + + return response?.brands?.[0] ?? EMPTY_PROFILE; + } catch (error) { + console.error('Error fetching public profile info.'); + throw error; + } + }, + enabled: address !== undefined, + retry: false, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (isPublicProfileError) { + toast.error('Failed to fetch public profile.'); + } + }, [isPublicProfileError]); + + const isLoading: boolean = + isLoadingBrandingPlatforms || isLoadingProfileInfo || isFetchingProfileInfo || address === undefined; + + // Build the image URL once (with cache-busting param when refreshed) + const profileImageUrl = useMemo(() => { + if (!address) { + return ''; + } + + const base = `${config.backendUrl}/branding/get-brand-logo?address=${address}`; + return imageRefreshToken ? `${base}&t=${imageRefreshToken}` : base; + }, [address, imageRefreshToken]); + + return ( + +
+ {/* Image */} +
+
+ {/* Placeholder */} +
+ +
+ + {/* Loading */} + {isImageLoading && } + + Profile { + setImageLoading(false); + }} + onError={() => { + setImageLoading(false); + setImageError(true); + }} + /> +
+ +
+ { + // Bust cached image and re-attempt load + setImageError(false); + setImageRefreshToken(Date.now()); + }} + setImageLoading={setImageLoading} + /> + +
Only .jpg, .jpeg, and .png images are allowed.
+
+
+ + {/* Details */} + {isEditing ? ( + { + setEditing(false); + refetchPublicProfileInfo(); + }} + /> + ) : ( + <> +
Name
+ + {isLoading ? ( + + ) : ( + +
+ {profileInfo.name?.length > 0 ? profileInfo.name :
} +
+
+ )} + +
Description
+ + {isLoading ? ( + + ) : ( + +
+ {profileInfo.description?.length > 0 ? ( + profileInfo.description + ) : ( +
+ )} +
+
+ )} + +
Links
+ + {isLoading ? ( + + ) : ( + +
+ {brandingPlatforms.map((platform) => { + const value = profileInfo.links[platform]; + + const displayValue = + typeof value === 'string' && value.length > 0 ? ( + value + ) : ( +
+ ); + + return ( + + ); + })} +
+
+ )} + +
+ +
+ + )} +
+
+ ); +} diff --git a/src/components/account/profile/WalletInformation.tsx b/src/components/account/profile/WalletInformation.tsx new file mode 100644 index 00000000..185f4b7d --- /dev/null +++ b/src/components/account/profile/WalletInformation.tsx @@ -0,0 +1,33 @@ +import { config } from '@lib/config'; +import { BlockchainContextType, useBlockchainContext } from '@lib/contexts/blockchain'; +import { fBI } from '@lib/utils'; +import { DetailsCard } from '@shared/cards/DetailsCard'; +import ProfileRow from '@shared/ProfileRow'; +import { useEffect, useState } from 'react'; +import ProfileSectionWrapper from './ProfileSectionWrapper'; + +export default function WalletInformation() { + const { fetchErc20Balance } = useBlockchainContext() as BlockchainContextType; + + const [usdcBalance, setUsdcBalance] = useState(0n); + + // Init + useEffect(() => { + fetchErc20Balance(config.usdcContractAddress).then((balance) => { + setUsdcBalance(balance); + }); + }, []); + + return ( + + {/* Account */} +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/create-job/JobFormButtons.tsx b/src/components/create-job/JobFormButtons.tsx index e0a0f305..4da6e815 100644 --- a/src/components/create-job/JobFormButtons.tsx +++ b/src/components/create-job/JobFormButtons.tsx @@ -10,11 +10,18 @@ interface Props { cancelLabel: string; onCancel?: () => void; customSubmitButton?: React.ReactNode; - isEditingJob?: boolean; + isEditingRunningJob?: boolean; disableNextStep?: boolean; } -function JobFormButtons({ steps, cancelLabel, onCancel, customSubmitButton, isEditingJob, disableNextStep = false }: Props) { +function JobFormButtons({ + steps, + cancelLabel, + onCancel, + customSubmitButton, + isEditingRunningJob, + disableNextStep = false, +}: Props) { const { step, setStep, setJobType } = useDeploymentContext() as DeploymentContextType; const { trigger, getValues, formState } = useFormContext(); @@ -71,7 +78,7 @@ function JobFormButtons({ steps, cancelLabel, onCancel, customSubmitButton, isEd
Go back: {step === 0 ? cancelLabel : steps[step - 1].title}
- {step === steps.length - 1 && !isEditingJob && ( + {step === steps.length - 1 && !isEditingRunningJob && ( <> @@ -38,6 +38,7 @@ export default function PluginEnvVariablesSection({ baseName }) { diff --git a/src/components/create-job/steps/CostAndDuration.tsx b/src/components/create-job/steps/CostAndDuration.tsx index 4b899c11..e4436a20 100644 --- a/src/components/create-job/steps/CostAndDuration.tsx +++ b/src/components/create-job/steps/CostAndDuration.tsx @@ -10,7 +10,7 @@ import { } from '@lib/deeploy-utils'; import CostAndDurationInterface from '@shared/jobs/CostAndDurationInterface'; import { JobCostAndDuration, JobSpecifications, JobType } from '@typedefs/deeploys'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useFormContext } from 'react-hook-form'; function CostAndDuration() { @@ -74,6 +74,11 @@ function CostAndDuration() { [costPerEpoch, duration], ); + // Init + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + return ( { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + const getComponent = () => { switch (jobType) { case JobType.Generic: - return ; + return ; case JobType.Native: - return ; + return ; case JobType.Service: - return ; + return ; default: return
Error: Unknown deployment type
; diff --git a/src/components/create-job/steps/Plugins.tsx b/src/components/create-job/steps/Plugins.tsx new file mode 100644 index 00000000..8e472793 --- /dev/null +++ b/src/components/create-job/steps/Plugins.tsx @@ -0,0 +1,11 @@ +import { useEffect } from 'react'; +import PluginsSection from '../plugins/PluginsSection'; + +export default function Plugins() { + // Init + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + return ; +} diff --git a/src/components/create-job/steps/Specifications.tsx b/src/components/create-job/steps/Specifications.tsx index 0beca5e7..3b370a7f 100644 --- a/src/components/create-job/steps/Specifications.tsx +++ b/src/components/create-job/steps/Specifications.tsx @@ -1,27 +1,33 @@ import { JobType } from '@typedefs/deeploys'; +import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import GenericSpecifications from './specifications/GenericSpecifications'; import NativeSpecifications from './specifications/NativeSpecifications'; import ServiceSpecifications from './specifications/ServiceSpecifications'; function Specifications({ - isEditingJob, + isEditingRunningJob, initialTargetNodesCount, onTargetNodesCountDecrease, }: { - isEditingJob?: boolean; + isEditingRunningJob?: boolean; initialTargetNodesCount?: number; onTargetNodesCountDecrease?: (blocked: boolean) => void; }) { const { watch } = useFormContext(); const jobType = watch('jobType'); + // Init + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + const getComponent = () => { switch (jobType) { case JobType.Generic: return ( @@ -30,14 +36,14 @@ function Specifications({ case JobType.Native: return ( ); case JobType.Service: - return ; + return ; default: return
Error: Unknown specifications type
; diff --git a/src/components/create-job/steps/deployment/GenericDeployment.tsx b/src/components/create-job/steps/deployment/GenericDeployment.tsx index 56573a63..86ca12c2 100644 --- a/src/components/create-job/steps/deployment/GenericDeployment.tsx +++ b/src/components/create-job/steps/deployment/GenericDeployment.tsx @@ -8,8 +8,9 @@ import EnvVariablesCard from '@shared/jobs/EnvVariablesCard'; import FileVolumesSection from '@shared/jobs/FileVolumesSection'; import KeyValueEntriesSection from '@shared/jobs/KeyValueEntriesSection'; import TargetNodesCard from '@shared/jobs/target-nodes/TargetNodesCard'; +import PortMappingSection from '@shared/PortMappingSection'; -function GenericDeployment({ isEditingJob }: { isEditingJob?: boolean }) { +function GenericDeployment({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) { return (
@@ -18,14 +19,18 @@ function GenericDeployment({ isEditingJob }: { isEditingJob?: boolean }) {
- + - + + + + + @@ -33,7 +38,12 @@ function GenericDeployment({ isEditingJob }: { isEditingJob?: boolean }) { - + diff --git a/src/components/create-job/steps/deployment/NativeDeployment.tsx b/src/components/create-job/steps/deployment/NativeDeployment.tsx index 8fc77f3e..2c7ccbbf 100644 --- a/src/components/create-job/steps/deployment/NativeDeployment.tsx +++ b/src/components/create-job/steps/deployment/NativeDeployment.tsx @@ -1,44 +1,20 @@ -import AppParametersSection from '@components/create-job/sections/AppParametersSection'; -import { BOOLEAN_TYPES } from '@data/booleanTypes'; -import { pluginSignaturesCustomParams } from '@data/default-values/customParams'; import { PIPELINE_INPUT_TYPES } from '@data/pipelineInputTypes'; -import { PLUGIN_SIGNATURE_TYPES } from '@data/pluginSignatureTypes'; import { SlateCard } from '@shared/cards/SlateCard'; import InputWithLabel from '@shared/InputWithLabel'; +import DeeployWarningAlert from '@shared/jobs/DeeployWarningAlert'; import KeyValueEntriesSection from '@shared/jobs/KeyValueEntriesSection'; -import NativeAppIdentitySection from '@shared/jobs/native/NativeAppIdentitySection'; import TargetNodesCard from '@shared/jobs/target-nodes/TargetNodesCard'; import Label from '@shared/Label'; import SelectWithLabel from '@shared/SelectWithLabel'; -import { useFormContext } from 'react-hook-form'; -import SecondaryPluginsCard from '../../secondary-plugins/SecondaryPluginsCard'; - -function NativeDeployment({ isEditingJob }: { isEditingJob?: boolean }) { - const { watch } = useFormContext(); - - const pluginSignature: (typeof PLUGIN_SIGNATURE_TYPES)[number] = watch('deployment.pluginSignature'); +function NativeDeployment({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) { return (
- + - - - - - - - - - +
@@ -69,10 +45,16 @@ function NativeDeployment({ isEditingJob }: { isEditingJob?: boolean }) {
- - - - + + Implementation Required
} + description={ +
+ Make sure your app complies with the chainstore response mechanism; otherwise, your deployment will + time out. +
+ } + />
); diff --git a/src/components/create-job/steps/deployment/ServiceDeployment.tsx b/src/components/create-job/steps/deployment/ServiceDeployment.tsx index e4ef5da2..52380018 100644 --- a/src/components/create-job/steps/deployment/ServiceDeployment.tsx +++ b/src/components/create-job/steps/deployment/ServiceDeployment.tsx @@ -6,10 +6,11 @@ import InputWithLabel from '@shared/InputWithLabel'; import ServiceInputsSection from '@shared/jobs/ServiceInputsSection'; import TargetNodesCard from '@shared/jobs/target-nodes/TargetNodesCard'; import { JobSpecifications, JobType } from '@typedefs/deeploys'; +import clsx from 'clsx'; import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; -function ServiceDeployment({ isEditingJob }: { isEditingJob?: boolean }) { +function ServiceDeployment({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) { const { watch, setValue } = useFormContext(); const jobType: JobType = watch('jobType'); @@ -19,29 +20,43 @@ function ServiceDeployment({ isEditingJob }: { isEditingJob?: boolean }) { // Init useEffect(() => { - if (!isEditingJob && containerOrWorkerType) { + if (!isEditingRunningJob && containerOrWorkerType) { setValue('deployment.jobAlias', containerOrWorkerType.notes.split(' ')[0]?.toLowerCase()); setValue('deployment.port', containerOrWorkerType.port); } - }, [isEditingJob, containerOrWorkerType]); + }, [isEditingRunningJob, containerOrWorkerType]); return (
- + +
+
{containerOrWorkerType.tag.text}
+
+
+ ) : null + } + >
- + - + - {containerOrWorkerType.inputs && ( - - )} + {containerOrWorkerType.inputs && } {/* diff --git a/src/components/create-job/steps/specifications/GenericSpecifications.tsx b/src/components/create-job/steps/specifications/GenericSpecifications.tsx index 12da2d9b..a7692222 100644 --- a/src/components/create-job/steps/specifications/GenericSpecifications.tsx +++ b/src/components/create-job/steps/specifications/GenericSpecifications.tsx @@ -7,11 +7,11 @@ import SpecsNodesSection from '@shared/jobs/SpecsNodesSection'; import { JobType } from '@typedefs/deeploys'; export default function GenericSpecifications({ - isEditingJob, + isEditingRunningJob, initialTargetNodesCount, onTargetNodesCountDecrease, }: { - isEditingJob?: boolean; + isEditingRunningJob?: boolean; initialTargetNodesCount?: number; onTargetNodesCountDecrease?: (blocked: boolean) => void; }) { @@ -22,21 +22,17 @@ export default function GenericSpecifications({ name="specifications.containerType" label="Container Type" options={genericContainerTypes} - isDisabled={isEditingJob} + isDisabled={isEditingRunningJob} /> - + - + diff --git a/src/components/create-job/steps/specifications/NativeSpecifications.tsx b/src/components/create-job/steps/specifications/NativeSpecifications.tsx index 4776b41a..ce0dbc85 100644 --- a/src/components/create-job/steps/specifications/NativeSpecifications.tsx +++ b/src/components/create-job/steps/specifications/NativeSpecifications.tsx @@ -7,11 +7,11 @@ import SpecsNodesSection from '@shared/jobs/SpecsNodesSection'; import { JobType } from '@typedefs/deeploys'; export default function NativeSpecifications({ - isEditingJob, + isEditingRunningJob, initialTargetNodesCount, onTargetNodesCountDecrease, }: { - isEditingJob?: boolean; + isEditingRunningJob?: boolean; initialTargetNodesCount?: number; onTargetNodesCountDecrease?: (blocked: boolean) => void; }) { @@ -22,17 +22,17 @@ export default function NativeSpecifications({ name="specifications.workerType" label="Worker Type" options={nativeWorkerTypes} - isDisabled={isEditingJob} + isDisabled={isEditingRunningJob} /> - + - + diff --git a/src/components/create-job/steps/specifications/ServiceSpecifications.tsx b/src/components/create-job/steps/specifications/ServiceSpecifications.tsx index 2aecfebd..bcdecc41 100644 --- a/src/components/create-job/steps/specifications/ServiceSpecifications.tsx +++ b/src/components/create-job/steps/specifications/ServiceSpecifications.tsx @@ -1,14 +1,11 @@ -import { APPLICATION_TYPES } from '@data/applicationTypes'; import { serviceContainerTypes } from '@data/containerResources'; import { SlateCard } from '@shared/cards/SlateCard'; import ContainerResourcesInfo from '@shared/jobs/ContainerResourcesInfo'; import SelectContainerOrWorkerType from '@shared/jobs/SelectContainerOrWorkerType'; import JobTags from '@shared/jobs/target-nodes/JobTags'; import NumberInputWithLabel from '@shared/NumberInputWithLabel'; -import SelectWithLabel from '@shared/SelectWithLabel'; -import { JobType } from '@typedefs/deeploys'; -export default function ServiceSpecifications({ isEditingJob }: { isEditingJob?: boolean }) { +export default function ServiceSpecifications({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) { return (
@@ -16,25 +13,21 @@ export default function ServiceSpecifications({ isEditingJob }: { isEditingJob?: name="specifications.containerType" label="Container Type" options={serviceContainerTypes} - isDisabled={isEditingJob} + isDisabled={isEditingRunningJob} /> - +
- + /> */}
diff --git a/src/components/deeploys/RunningCard.tsx b/src/components/deeploys/RunningCard.tsx index 50dce979..5de3d833 100644 --- a/src/components/deeploys/RunningCard.tsx +++ b/src/components/deeploys/RunningCard.tsx @@ -8,7 +8,7 @@ import Expander from '@shared/Expander'; import Usage from '@shared/projects/Usage'; import { SmallTag } from '@shared/SmallTag'; import { RunningJob, RunningJobWithDetails } from '@typedefs/deeploys'; -import { JobTypeOption, jobTypeOptions } from '@typedefs/jobType'; +import { JOB_TYPE_OPTIONS, JobTypeOption } from '@typedefs/jobType'; import { addDays, differenceInDays, formatDistanceStrict } from 'date-fns'; import _ from 'lodash'; import { RiCalendarLine } from 'react-icons/ri'; @@ -121,7 +121,7 @@ export default function RunningCard({ const { jobType } = resources; - const jobTypeOption = jobTypeOptions.find( + const jobTypeOption = JOB_TYPE_OPTIONS.find( (option) => option.id === jobType.toLowerCase(), ) as JobTypeOption; diff --git a/src/components/draft/DraftEditFormWrapper.tsx b/src/components/draft/DraftEditFormWrapper.tsx index 51f355f9..d099c5ae 100644 --- a/src/components/draft/DraftEditFormWrapper.tsx +++ b/src/components/draft/DraftEditFormWrapper.tsx @@ -1,10 +1,10 @@ import JobFormButtons from '@components/create-job/JobFormButtons'; import CostAndDuration from '@components/create-job/steps/CostAndDuration'; import Deployment from '@components/create-job/steps/Deployment'; +import Plugins from '@components/create-job/steps/Plugins'; import Specifications from '@components/create-job/steps/Specifications'; import { APPLICATION_TYPES } from '@data/applicationTypes'; import { BOOLEAN_TYPES } from '@data/booleanTypes'; -import { DYNAMIC_ENV_TYPES } from '@data/dynamicEnvTypes'; import { PIPELINE_INPUT_TYPES } from '@data/pipelineInputTypes'; import { zodResolver } from '@hookform/resolvers/zod'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; @@ -12,11 +12,14 @@ import { jobSchema } from '@schemas/index'; import JobFormHeaderInterface from '@shared/jobs/JobFormHeaderInterface'; import SubmitButton from '@shared/SubmitButton'; import { DraftJob, GenericDraftJob, JobType, NativeDraftJob, ServiceDraftJob } from '@typedefs/deeploys'; +import { ContainerDeploymentType, DeploymentType, PluginType, WorkerDeploymentType } from '@typedefs/steps/deploymentStepTypes'; +import { cloneDeep } from 'lodash'; +import { useEffect, useState } from 'react'; import { FieldErrors, FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import z from 'zod'; -const STEPS: { +const DEFAULT_STEPS: { title: string; validationName?: string; }[] = [ @@ -36,24 +39,22 @@ export default function DraftEditFormWrapper({ const navigate = useNavigate(); - const cloneKeyValueEntries = (entries?: Array<{ key: string; value: string }>) => - entries ? entries.map((entry) => ({ key: entry.key, value: entry.value })) : []; - - const cloneDynamicEnvEntries = ( - entries?: Array<{ - key: string; - values: Array<{ type: (typeof DYNAMIC_ENV_TYPES)[number]; value: string }>; - }>, - ) => - entries - ? entries.map((entry) => ({ - key: entry.key, - values: entry.values.map((value) => ({ type: value.type, value: value.value })), - })) - : []; + const [steps, setSteps] = useState(DEFAULT_STEPS); - const cloneDeploymentNodes = (nodes?: Array<{ address: string }>) => - nodes && nodes.length ? nodes.map((node) => ({ address: node.address })) : [{ address: '' }]; + const getBaseSchemaDeploymentDefaults = () => ({ + jobAlias: job.deployment.jobAlias, + autoAssign: job.deployment.autoAssign ?? true, + targetNodes: cloneDeploymentNodes(job.deployment.targetNodes), + spareNodes: cloneDeploymentNodes(job.deployment.spareNodes), + allowReplicationInTheWild: job.deployment.allowReplicationInTheWild ?? true, + }); + + const getBaseSchemaTunnelingDefaults = () => ({ + enableTunneling: job.deployment.enableTunneling ?? BOOLEAN_TYPES[0], + port: job.deployment.port ?? '', + tunnelingToken: job.deployment.tunnelingToken, + tunnelingLabel: job.deployment.tunnelingLabel, + }); const getBaseSchemaDefaults = () => ({ jobType: job.jobType, @@ -68,13 +69,8 @@ export default function DraftEditFormWrapper({ paymentMonthsCount: job.costAndDuration.paymentMonthsCount, }, deployment: { - autoAssign: job.deployment.autoAssign ?? true, - targetNodes: cloneDeploymentNodes(job.deployment.targetNodes), - spareNodes: cloneDeploymentNodes(job.deployment.spareNodes), - allowReplicationInTheWild: job.deployment.allowReplicationInTheWild ?? true, - enableTunneling: job.deployment.enableTunneling ?? BOOLEAN_TYPES[0], - tunnelingToken: job.deployment.tunnelingToken, - tunnelingLabel: job.deployment.tunnelingLabel, + ...getBaseSchemaDeploymentDefaults(), + ...getBaseSchemaTunnelingDefaults(), }, }); @@ -83,6 +79,29 @@ export default function DraftEditFormWrapper({ const genericJob = job as GenericDraftJob; const deployment = genericJob.deployment; + let deploymentType: DeploymentType; + + if (deployment.deploymentType.pluginType === PluginType.Container) { + const containerDeploymentType: ContainerDeploymentType = deployment.deploymentType as ContainerDeploymentType; + + deploymentType = { + ...deployment.deploymentType, + crUsername: containerDeploymentType.crUsername ?? '', + crPassword: containerDeploymentType.crPassword ?? '', + }; + } else { + const workerDeploymentType: WorkerDeploymentType = deployment.deploymentType as WorkerDeploymentType; + + deploymentType = { + ...deployment.deploymentType, + workerCommands: workerDeploymentType.workerCommands.map((command) => ({ + command: command.command, + })), + username: workerDeploymentType.username ?? '', + accessToken: workerDeploymentType.accessToken ?? '', + }; + } + return { ...baseDefaults, specifications: { @@ -92,28 +111,12 @@ export default function DraftEditFormWrapper({ }, deployment: { ...baseDefaults.deployment, - jobAlias: deployment.jobAlias, - deploymentType: - deployment.deploymentType.type === 'container' - ? { - ...deployment.deploymentType, - crUsername: deployment.deploymentType.crUsername ?? '', - crPassword: deployment.deploymentType.crPassword ?? '', - } - : { - ...deployment.deploymentType, - workerCommands: deployment.deploymentType.workerCommands.map((command) => ({ - command: command.command, - })), - username: deployment.deploymentType.username ?? '', - accessToken: deployment.deploymentType.accessToken ?? '', - }, - port: deployment.port ?? '', - restartPolicy: deployment.restartPolicy, - imagePullPolicy: deployment.imagePullPolicy, - envVars: cloneKeyValueEntries(deployment.envVars), - dynamicEnvVars: cloneDynamicEnvEntries(deployment.dynamicEnvVars), - volumes: cloneKeyValueEntries(deployment.volumes), + deploymentType, + ports: cloneDeep(deployment.ports), + // Variables + envVars: cloneDeep(deployment.envVars), + dynamicEnvVars: cloneDeep(deployment.dynamicEnvVars), + volumes: cloneDeep(deployment.volumes), fileVolumes: deployment.fileVolumes ? deployment.fileVolumes.map((fileVolume) => ({ name: fileVolume.name, @@ -121,6 +124,9 @@ export default function DraftEditFormWrapper({ content: fileVolume.content, })) : [], + // Policies + restartPolicy: deployment.restartPolicy, + imagePullPolicy: deployment.imagePullPolicy, }, } as z.infer; }; @@ -138,17 +144,14 @@ export default function DraftEditFormWrapper({ gpuType: nativeJob.specifications.gpuType, }, deployment: { - ...baseDefaults.deployment, - jobAlias: deployment.jobAlias, - port: deployment.port ?? '', - pluginSignature: deployment.pluginSignature, - customPluginSignature: deployment.customPluginSignature, - customParams: cloneKeyValueEntries(deployment.customParams), - pipelineParams: cloneKeyValueEntries(deployment.pipelineParams), + ...getBaseSchemaDeploymentDefaults(), + // Pipeline + pipelineParams: cloneDeep(deployment.pipelineParams), pipelineInputType: deployment.pipelineInputType ?? PIPELINE_INPUT_TYPES[0], pipelineInputUri: deployment.pipelineInputUri, chainstoreResponse: deployment.chainstoreResponse ?? BOOLEAN_TYPES[1], }, + plugins: cloneDeep(deployment.plugins), } as z.infer; }; @@ -165,12 +168,15 @@ export default function DraftEditFormWrapper({ }, deployment: { ...baseDefaults.deployment, - jobAlias: deployment.jobAlias, + inputs: cloneDeep(deployment.inputs), serviceReplica: deployment.serviceReplica ?? '', }, } as z.infer; }; + const cloneDeploymentNodes = (nodes?: Array<{ address: string }>) => + nodes && nodes.length ? nodes.map((node) => ({ address: node.address })) : [{ address: '' }]; + const getDefaultSchemaValues = () => { switch (job.jobType) { case JobType.Generic: @@ -193,6 +199,12 @@ export default function DraftEditFormWrapper({ defaultValues: getDefaultSchemaValues(), }); + useEffect(() => { + if (job.jobType === JobType.Native) { + setSteps([...DEFAULT_STEPS, { title: 'Plugins' }]); + } + }, [job]); + const onError = (errors: FieldErrors>) => { console.log(errors); }; @@ -204,7 +216,7 @@ export default function DraftEditFormWrapper({
step.title)} + steps={steps.map((step) => step.title)} onCancel={() => { navigate(-1); }} @@ -215,11 +227,12 @@ export default function DraftEditFormWrapper({ {step === 0 && } {step === 1 && } {step === 2 && } + {step === 3 && } } + customSubmitButton={} />
diff --git a/src/components/draft/JobDraftBreadcrumbs.tsx b/src/components/draft/JobDraftBreadcrumbs.tsx index f7f87dea..3ebd5b07 100644 --- a/src/components/draft/JobDraftBreadcrumbs.tsx +++ b/src/components/draft/JobDraftBreadcrumbs.tsx @@ -3,7 +3,7 @@ import { routePath } from '@lib/routes/route-paths'; import db from '@lib/storage/db'; import { SmallTag } from '@shared/SmallTag'; import { DraftJob, DraftProject } from '@typedefs/deeploys'; -import { JobTypeOption, jobTypeOptions } from '@typedefs/jobType'; +import { JOB_TYPE_OPTIONS, JobTypeOption } from '@typedefs/jobType'; import { useLiveQuery } from 'dexie-react-hooks'; import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; @@ -22,7 +22,7 @@ export default function JobDraftBreadcrumbs({ jobDraft }: { jobDraft: DraftJob } useEffect(() => { if (jobDraft) { - setJobTypeOption(jobTypeOptions.find((option) => option.jobType === jobDraft.jobType)); + setJobTypeOption(JOB_TYPE_OPTIONS.find((option) => option.jobType === jobDraft.jobType)); } }, [jobDraft]); diff --git a/src/components/draft/job-rundowns/GenericJobsCostRundown.tsx b/src/components/draft/job-rundowns/GenericJobsCostRundown.tsx index fc6913e1..1965203a 100644 --- a/src/components/draft/job-rundowns/GenericJobsCostRundown.tsx +++ b/src/components/draft/job-rundowns/GenericJobsCostRundown.tsx @@ -2,6 +2,7 @@ import { ContainerOrWorkerType, genericContainerTypes, GpuType, gpuTypes } from import { getContainerOrWorkerTypeDescription } from '@lib/deeploy-utils'; import JobsCostRundown from '@shared/jobs/drafts/JobsCostRundown'; import { GenericDraftJob } from '@typedefs/deeploys'; +import { ContainerDeploymentType, DeploymentType, PluginType, WorkerDeploymentType } from '@typedefs/steps/deploymentStepTypes'; import { RiBox3Line } from 'react-icons/ri'; export default function GenericJobsCostRundown({ jobs }: { jobs: GenericDraftJob[] }) { @@ -24,6 +25,8 @@ export default function GenericJobsCostRundown({ jobs }: { jobs: GenericDraftJob ? gpuTypes.find((type) => type.name === genericJob.specifications.gpuType) : undefined; + const deploymentType: DeploymentType = genericJob.deployment.deploymentType; + const entries = [ // Alias { label: 'Alias', value: genericJob.deployment.jobAlias }, @@ -37,29 +40,38 @@ export default function GenericJobsCostRundown({ jobs }: { jobs: GenericDraftJob }, ...(gpuType ? [{ label: 'GPU Type', value: `${gpuType.name} (${gpuType.gpus.join(', ')})` }] : []), - // Deployment - { - label: genericJob.deployment.deploymentType.type === 'container' ? 'Container Image' : 'Repository URL', - value: - genericJob.deployment.deploymentType.type === 'container' - ? genericJob.deployment.deploymentType.containerImage - : genericJob.deployment.deploymentType.repositoryUrl, - }, - ...(genericJob.deployment.deploymentType.type === 'container' - ? [{ label: 'Registry Visibility', value: genericJob.deployment.deploymentType.crVisibility }] - : []), - ...(genericJob.deployment.deploymentType.type === 'worker' - ? [{ label: 'Image', value: genericJob.deployment.deploymentType.image }] - : []), + // Tunneling { label: 'Port', value: genericJob.deployment.port ?? '—' }, { label: 'Tunneling', value: genericJob.deployment.enableTunneling }, ...(genericJob.deployment.enableTunneling === 'True' && genericJob.deployment.tunnelingLabel ? [{ label: 'Tunneling Label', value: genericJob.deployment.tunnelingLabel }] : []), + + // Policies { label: 'Restart Policy', value: genericJob.deployment.restartPolicy }, { label: 'Image Pull Policy', value: genericJob.deployment.imagePullPolicy }, ]; + if (deploymentType.pluginType === PluginType.Container) { + const containerDeploymentType: ContainerDeploymentType = deploymentType as ContainerDeploymentType; + + entries.push( + ...[ + { label: 'Container Image', value: containerDeploymentType.containerImage }, + { label: 'Registry Visibility', value: containerDeploymentType.crVisibility }, + ], + ); + } else { + const workerDeploymentType: WorkerDeploymentType = deploymentType as WorkerDeploymentType; + + entries.push( + ...[ + { label: 'Repository URL', value: workerDeploymentType.repositoryUrl }, + { label: 'Image', value: workerDeploymentType.image }, + ], + ); + } + return (
{entries.map((entry, index) => ( diff --git a/src/components/draft/job-rundowns/NativeJobsCostRundown.tsx b/src/components/draft/job-rundowns/NativeJobsCostRundown.tsx index 22c9f629..2417352b 100644 --- a/src/components/draft/job-rundowns/NativeJobsCostRundown.tsx +++ b/src/components/draft/job-rundowns/NativeJobsCostRundown.tsx @@ -1,9 +1,8 @@ import { ContainerOrWorkerType, GpuType, gpuTypes, nativeWorkerTypes } from '@data/containerResources'; -import { PLUGIN_SIGNATURE_TYPES } from '@data/pluginSignatureTypes'; import { getContainerOrWorkerTypeDescription } from '@lib/deeploy-utils'; import JobsCostRundown from '@shared/jobs/drafts/JobsCostRundown'; import { NativeDraftJob } from '@typedefs/deeploys'; -import { GenericSecondaryPlugin, SecondaryPlugin, SecondaryPluginType } from '@typedefs/steps/deploymentStepTypes'; +import { BasePluginType, GenericPlugin, Plugin, PluginType } from '@typedefs/steps/deploymentStepTypes'; import { RiTerminalBoxLine } from 'react-icons/ri'; export default function NativeJobsCostRundown({ jobs }: { jobs: NativeDraftJob[] }) { @@ -37,34 +36,23 @@ export default function NativeJobsCostRundown({ jobs }: { jobs: NativeDraftJob[] ...(gpuType ? [{ label: 'GPU Type', value: `${gpuType.name} (${gpuType.gpus.join(', ')})` }] : []), // Deployment - { - label: 'Plugin Signature', - value: - nativeJob.deployment.pluginSignature === PLUGIN_SIGNATURE_TYPES[PLUGIN_SIGNATURE_TYPES.length - 1] - ? nativeJob.deployment.customPluginSignature - : nativeJob.deployment.pluginSignature, - }, { label: 'Pipeline Input Type', value: nativeJob.deployment.pipelineInputType }, { label: 'Pipeline Input URI', value: nativeJob.deployment.pipelineInputUri ?? 'None' }, - { label: 'Tunneling', value: nativeJob.deployment.enableTunneling }, - ...(nativeJob.deployment.enableTunneling === 'True' && nativeJob.deployment.tunnelingLabel - ? [{ label: 'Tunneling Label', value: nativeJob.deployment.tunnelingLabel }] - : []), { label: 'Chainstore Response', value: nativeJob.deployment.chainstoreResponse }, ]; - if (nativeJob.deployment.secondaryPlugins.length) { + if (nativeJob.deployment.plugins?.length) { entries.push({ - label: 'Secondary Plugins', - value: nativeJob.deployment.secondaryPlugins - .map((plugin: SecondaryPlugin) => { - switch (plugin.secondaryPluginType) { - case SecondaryPluginType.Generic: - return (plugin as GenericSecondaryPlugin).deploymentType.type === 'container' + label: 'Plugins', + value: nativeJob.deployment.plugins + .map((plugin: Plugin) => { + switch (plugin.basePluginType) { + case BasePluginType.Generic: + return (plugin as GenericPlugin).deploymentType.pluginType === PluginType.Container ? 'Container App Runner' : 'Worker App Runner'; - case SecondaryPluginType.Native: + case BasePluginType.Native: return 'Native Plugin'; default: diff --git a/src/components/edit-job/JobEditFormWrapper.tsx b/src/components/edit-job/JobEditFormWrapper.tsx index 0feb4153..7cf496d4 100644 --- a/src/components/edit-job/JobEditFormWrapper.tsx +++ b/src/components/edit-job/JobEditFormWrapper.tsx @@ -1,19 +1,20 @@ import JobFormButtons from '@components/create-job/JobFormButtons'; import Deployment from '@components/create-job/steps/Deployment'; +import Plugins from '@components/create-job/steps/Plugins'; import Specifications from '@components/create-job/steps/Specifications'; import { APPLICATION_TYPES } from '@data/applicationTypes'; import { BOOLEAN_TYPES } from '@data/booleanTypes'; import { CR_VISIBILITY_OPTIONS } from '@data/crVisibilityOptions'; -import { PIPELINE_INPUT_TYPES } from '@data/pipelineInputTypes'; -import { PLUGIN_SIGNATURE_TYPES } from '@data/pluginSignatureTypes'; import { zodResolver } from '@hookform/resolvers/zod'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; -import { boolToBooleanType, titlecase } from '@lib/deeploy-utils'; +import { boolToBooleanType, isGenericPlugin, NATIVE_PLUGIN_DEFAULT_RESPONSE_KEYS, titlecase } from '@lib/deeploy-utils'; import { jobSchema } from '@schemas/index'; import JobFormHeaderInterface from '@shared/jobs/JobFormHeaderInterface'; import PayButtonWithAllowance from '@shared/jobs/PayButtonWithAllowance'; -import { JobConfig } from '@typedefs/deeployApi'; +import { AppsPlugin, JobConfig } from '@typedefs/deeployApi'; import { JobType, RunningJobWithResources } from '@typedefs/deeploys'; +import { BasePluginType, CustomParameterEntry, PluginType } from '@typedefs/steps/deploymentStepTypes'; +import _ from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { FieldErrors, FormProvider, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; @@ -21,7 +22,7 @@ import { useNavigate } from 'react-router-dom'; import z from 'zod'; import ReviewAndConfirm from './ReviewAndConfirm'; -const STEPS: { +const DEFAULT_STEPS: { title: string; validationName?: string; }[] = [ @@ -47,16 +48,93 @@ export default function JobEditFormWrapper({ const hasModifiedStepsRef = useRef(false); const payButtonRef = useRef<{ fetchAllowance: () => Promise }>(null); - const config: JobConfig = job.config; + const jobConfig: JobConfig = job.config; + + // console.log('[JobEditFormWrapper]', { job, jobConfig }); - // Used only when editing a job const [isTargetNodesCountLower, setTargetNodesCountLower] = useState(false); const [additionalCost, setAdditionalCost] = useState(0n); - const getBaseSchemaDefaults = () => ({ + const [steps, setSteps] = useState(DEFAULT_STEPS); + + const getBaseSchemaDeploymentDefaults = () => ({ + jobAlias: job.alias, + autoAssign: false, + targetNodes: [ + ...job.nodes.map((address) => ({ address })), + ...Array.from({ length: Number(job.numberOfNodesRequested) - job.nodes.length }, () => ({ address: '' })), + ], + spareNodes: !job.spareNodes ? [] : job.spareNodes.map((address) => ({ address })), + allowReplicationInTheWild: job.allowReplicationInTheWild ?? false, + }); + + const getBaseSchemaTunnelingDefaults = (config: JobConfig) => ({ + enableTunneling: boolToBooleanType(config.TUNNEL_ENGINE_ENABLED), + port: config.PORT ?? '', + tunnelingToken: config.CLOUDFLARE_TOKEN || config.NGROK_AUTH_TOKEN, + }); + + const getGenericSpecificDeploymentDefaults = (config: JobConfig) => ({ + // Ports + ports: getPortMappings(config), + + // Deployment type + deploymentType: !config.VCS_DATA + ? { + pluginType: PluginType.Container, + containerImage: config.IMAGE, + containerRegistry: config.CR_DATA?.SERVER || 'docker.io', + crVisibility: CR_VISIBILITY_OPTIONS[!config.CR_DATA?.USERNAME ? 0 : 1], + crUsername: config.CR_DATA?.USERNAME || '', + crPassword: config.CR_DATA?.PASSWORD || '', + } + : { + pluginType: PluginType.Worker, + image: config.IMAGE, + repositoryUrl: config.VCS_DATA.REPO_URL, + repositoryVisibility: 'public', + username: config.VCS_DATA.USERNAME || '', + accessToken: config.VCS_DATA.TOKEN || '', + workerCommands: config.BUILD_AND_RUN_COMMANDS!.map((command) => ({ command })), + }, + + // Variables + envVars: getEnvVars(config), + dynamicEnvVars: getDynamicEnvVars(config), + volumes: getVolumes(config), + fileVolumes: getFileVolumes(config), + + // Policies + restartPolicy: titlecase(config.RESTART_POLICY!), + imagePullPolicy: titlecase(config.IMAGE_PULL_POLICY!), + }); + + const getGenericPluginSchemaDefaults = (config: JobConfig) => ({ + basePluginType: BasePluginType.Generic, + + // Tunneling + ...getBaseSchemaTunnelingDefaults(config), + + ...getGenericSpecificDeploymentDefaults(config), + }); + + const getNativePluginSchemaDefaults = (pluginInfo: AppsPlugin & { signature: string }) => ({ + basePluginType: BasePluginType.Native, + + // Signature + pluginSignature: pluginInfo.signature, + + // Tunneling + ...getBaseSchemaTunnelingDefaults(pluginInfo.instance_conf), + + // Custom Parameters + customParams: formatCustomParams(pluginInfo.instance_conf), + }); + + const getBaseSchemaDefaults = (config: JobConfig = jobConfig) => ({ jobType: job.resources.jobType, specifications: { - applicationType: APPLICATION_TYPES[0], // TODO: Get from job after the API update + applicationType: APPLICATION_TYPES[0], // Disabled for now targetNodesCount: Number(job.numberOfNodesRequested), jobTags: !job.jobTags ? [] : job.jobTags.filter((tag) => !tag.startsWith('CT:')), nodesCountries: !job.jobTags @@ -68,16 +146,8 @@ export default function JobEditFormWrapper({ paymentMonthsCount: 1, }, deployment: { - jobAlias: job.alias, - autoAssign: false, - targetNodes: [ - ...job.nodes.map((address) => ({ address })), - ...Array.from({ length: Number(job.numberOfNodesRequested) - job.nodes.length }, () => ({ address: '' })), - ], - spareNodes: !job.spareNodes ? [] : job.spareNodes.map((address) => ({ address })), - allowReplicationInTheWild: job.allowReplicationInTheWild ?? false, - enableTunneling: boolToBooleanType(config.TUNNEL_ENGINE_ENABLED), - tunnelingToken: !config.CLOUDFLARE_TOKEN ? undefined : config.CLOUDFLARE_TOKEN, + ...getBaseSchemaDeploymentDefaults(), + ...getBaseSchemaTunnelingDefaults(config), }, }); @@ -88,32 +158,10 @@ export default function JobEditFormWrapper({ containerType: job.resources.containerOrWorkerType.name, }, deployment: { + // Identity, Target nodes ...getBaseSchemaDefaults().deployment, - deploymentType: !config.VCS_DATA - ? { - type: 'container', - containerImage: config.IMAGE, - containerRegistry: config.CR_DATA?.SERVER || 'docker.io', - crVisibility: CR_VISIBILITY_OPTIONS[!config.CR_DATA?.USERNAME ? 0 : 1], - crUsername: config.CR_DATA?.USERNAME || '', - crPassword: config.CR_DATA?.PASSWORD || '', - } - : { - type: 'worker', - image: config.IMAGE, - repositoryUrl: config.VCS_DATA.REPO_URL, - repositoryVisibility: 'public', - username: config.VCS_DATA.USERNAME || '', - accessToken: config.VCS_DATA.TOKEN || '', - workerCommands: config.BUILD_AND_RUN_COMMANDS!.map((command) => ({ command })), - }, - port: config.PORT ?? '', - restartPolicy: titlecase(config.RESTART_POLICY!), - imagePullPolicy: titlecase(config.IMAGE_PULL_POLICY!), - envVars: getEnvVars(), - dynamicEnvVars: getDynamicEnvVars(), - volumes: getVolumes(), - fileVolumes: getFileVolumes(), + + ...getGenericSpecificDeploymentDefaults(jobConfig), }, }); @@ -124,14 +172,13 @@ export default function JobEditFormWrapper({ workerType: job.resources.containerOrWorkerType.name, }, deployment: { - ...getBaseSchemaDefaults().deployment, - port: config.PORT ?? '', - pluginSignature: PLUGIN_SIGNATURE_TYPES[0], // TODO: Native Job editing flow - customParams: [{ key: '', value: '' }], - pipelineParams: [{ key: '', value: '' }], - pipelineInputType: PIPELINE_INPUT_TYPES[0], + ...getBaseSchemaDeploymentDefaults(), + pipelineParams: getPipelineParams(), + pipelineInputType: job.pipelineData.TYPE, + pipelineInputUri: job.pipelineData.URL, chainstoreResponse: BOOLEAN_TYPES[1], }, + plugins: formatPlugins(), }); const getServiceSchemaDefaults = () => ({ @@ -142,25 +189,37 @@ export default function JobEditFormWrapper({ }, deployment: { ...getBaseSchemaDefaults().deployment, - envVars: getEnvVars(), - dynamicEnvVars: getDynamicEnvVars(), - volumes: getVolumes(), + tunnelingLabel: jobConfig.NGROK_EDGE_LABEL || '', + inputs: getEnvVars(jobConfig), }, }); - const getEnvVars = () => { + const getPipelineParams = () => { + return !job.pipelineParams ? [] : Object.entries(job.pipelineParams).map(([key, value]) => ({ key, value })); + }; + + const getPortMappings = (config: JobConfig) => { + return !config.CONTAINER_RESOURCES.ports + ? [] + : Object.entries(config.CONTAINER_RESOURCES.ports).map(([key, value]) => ({ + hostPort: Number(key), + containerPort: Number(value), + })); + }; + + const getEnvVars = (config: JobConfig) => { return !config.ENV ? [] : Object.entries(config.ENV).map(([key, value]) => ({ key, value })); }; - const getDynamicEnvVars = () => { + const getDynamicEnvVars = (config: JobConfig) => { return !config.DYNAMIC_ENV ? [] : Object.entries(config.DYNAMIC_ENV).map(([key, values]) => ({ key, values })); }; - const getVolumes = () => { + const getVolumes = (config: JobConfig) => { return !config.VOLUMES ? [] : Object.entries(config.VOLUMES).map(([key, value]) => ({ key, value })); }; - const getFileVolumes = () => { + const getFileVolumes = (config: JobConfig) => { return !config.FILE_VOLUMES ? [] : Object.entries(config.FILE_VOLUMES).map(([key, value]) => ({ @@ -170,6 +229,50 @@ export default function JobEditFormWrapper({ })); }; + const formatPlugins = () => { + // Get the instance with the most plugins + const instance = _(job.instances) + .sortBy((instance) => instance.plugins.length) + .last()!; + + const genericPluginConfigs: JobConfig[] = instance.plugins + .filter((plugin) => isGenericPlugin(plugin.signature)) + .map((plugin) => plugin.instance_conf); + + const nativePlugins = instance.plugins.filter((plugin) => !isGenericPlugin(plugin.signature)); + + return [ + ...nativePlugins.map((pluginInfo) => getNativePluginSchemaDefaults(pluginInfo)), + ...genericPluginConfigs.map((config) => getGenericPluginSchemaDefaults(config)), + ]; + }; + + const formatCustomParams = (config: JobConfig) => { + const customParams: CustomParameterEntry[] = []; + + Object.entries(config).forEach(([key, value]) => { + if (!NATIVE_PLUGIN_DEFAULT_RESPONSE_KEYS.includes(key as keyof JobConfig)) { + const valueType = typeof value === 'string' ? 'string' : 'json'; + + let parsedValue: string = ''; + + if (valueType === 'json') { + try { + parsedValue = JSON.stringify(value, null, 2); + } catch (error) { + console.error('[formatCustomParams()] Unable to parse JSON value', key, value); + } + } else { + parsedValue = value as string; + } + + customParams.push({ key, value: parsedValue, valueType }); + } + }); + + return customParams; + }; + const getDefaultSchemaValues = () => { switch (job.resources.jobType) { case JobType.Generic: @@ -203,6 +306,14 @@ export default function JobEditFormWrapper({ form.reset(defaults); setTargetNodesCountLower(false); setAdditionalCost(0n); + + if (job.resources.jobType === JobType.Native) { + setSteps([ + ...DEFAULT_STEPS.slice(0, DEFAULT_STEPS.length - 1), + { title: 'Plugins', validationName: 'plugins' }, + DEFAULT_STEPS[DEFAULT_STEPS.length - 1], + ]); + } }, [job, form]); useEffect(() => { @@ -233,7 +344,7 @@ export default function JobEditFormWrapper({
step.title)} + steps={steps.map((step) => step.title)} onCancel={() => { navigate(-1); }} @@ -243,13 +354,14 @@ export default function JobEditFormWrapper({ {step === 0 && ( )} - {step === 1 && } - {step === 2 && ( + {step === 1 && } + {step === 2 && } + {step === 3 && ( { navigate(-1); @@ -278,7 +390,7 @@ export default function JobEditFormWrapper({ />
} - isEditingJob + isEditingRunningJob disableNextStep={isTargetNodesCountLower} />
diff --git a/src/components/edit-job/ReviewAndConfirm.tsx b/src/components/edit-job/ReviewAndConfirm.tsx index 2ae33ff1..16cb2c3d 100644 --- a/src/components/edit-job/ReviewAndConfirm.tsx +++ b/src/components/edit-job/ReviewAndConfirm.tsx @@ -2,26 +2,26 @@ import { ContainerOrWorkerType } from '@data/containerResources'; import { getCurrentEpoch } from '@lib/config'; import { formatUsdc, getResourcesCostPerEpoch } from '@lib/deeploy-utils'; import { jobSchema } from '@schemas/index'; -import { BorderedCard } from '@shared/cards/BorderedCard'; import { SlateCard } from '@shared/cards/SlateCard'; import { SmallTag } from '@shared/SmallTag'; import { UsdcValue } from '@shared/UsdcValue'; -import { RunningJobWithResources } from '@typedefs/deeploys'; +import { JobType, RunningJobWithResources } from '@typedefs/deeploys'; import isEqual from 'lodash/isEqual'; import { useEffect, useMemo } from 'react'; +import type { FieldPath } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form'; import z from 'zod'; type JobFormValues = z.infer; -type StepKey = 'specifications' | 'costAndDuration' | 'deployment'; +type StepKey = 'specifications' | 'costAndDuration' | 'deployment' | 'plugins'; const hasDirtyFields = (dirtyValue: unknown): boolean => { - if (!dirtyValue) { - return false; + if (typeof dirtyValue === 'boolean') { + return dirtyValue; } - if (dirtyValue === true) { - return true; + if (!dirtyValue) { + return false; } if (Array.isArray(dirtyValue)) { @@ -54,6 +54,8 @@ export default function ReviewAndConfirm({ const specifications = useWatch({ control, name: 'specifications' }); const costAndDuration = useWatch({ control, name: 'costAndDuration' }); const deployment = useWatch({ control, name: 'deployment' }); + const plugins = useWatch({ control, name: 'plugins' as FieldPath }); + const { lastExecutionEpoch } = job; const currentTargetNodesCount = specifications?.targetNodesCount ?? defaultValues.specifications.targetNodesCount; @@ -76,10 +78,6 @@ export default function ReviewAndConfirm({ const containerOrWorkerType: ContainerOrWorkerType = job.resources.containerOrWorkerType; const costPerEpoch = getResourcesCostPerEpoch(containerOrWorkerType, job.resources.gpuType); - // console.log( - // `[ReviewAndConfirm] Additional cost: ${fBI(BigInt(increasedNodesCount) * costPerEpoch * remainingEpochs, 6, 2)} $USDC`, - // ); - return BigInt(increasedNodesCount) * costPerEpoch * remainingEpochs; }, [ currentTargetNodesCount, @@ -90,53 +88,96 @@ export default function ReviewAndConfirm({ specifications, ]); - const stepsStatus = useMemo( - () => - [ - { - key: 'specifications' as StepKey, - label: 'Specifications', - currentValue: specifications ?? defaultValues.specifications, - dirtyValue: (dirtyFields as Record | undefined)?.specifications, - children: - currentTargetNodesCount > defaultValues.specifications.targetNodesCount - ? [ - { - label: 'Target Nodes Count', - previousValue: defaultValues.specifications.targetNodesCount, - currentValue: currentTargetNodesCount, - }, - ] - : undefined, - }, - { - key: 'costAndDuration' as StepKey, - label: 'Duration', - currentValue: costAndDuration ?? defaultValues.costAndDuration, - dirtyValue: (dirtyFields as Record | undefined)?.costAndDuration, - }, - { - key: 'deployment' as StepKey, - label: 'Deployment', - currentValue: deployment ?? defaultValues.deployment, - dirtyValue: (dirtyFields as Record | undefined)?.deployment, - }, - ].map(({ key, label, currentValue, dirtyValue, children }) => { - const isDirty = hasDirtyFields(dirtyValue); - const hasChanged = !isEqual(currentValue, defaultValues[key]); - - return { - key, - label, - modified: isDirty && hasChanged, - children: children && isDirty && hasChanged ? children : undefined, - }; - }), - [costAndDuration, defaultValues, deployment, dirtyFields, specifications], - ); + const stepsStatus = useMemo(() => { + const dirtyFieldsRecord = dirtyFields as Record | undefined; + const defaultPlugins = defaultValues.jobType === JobType.Native ? defaultValues.plugins : undefined; + + const baseSteps: { + key: StepKey; + label: string; + currentValue: unknown; + defaultValue: unknown; + dirtyValue: unknown; + children?: + | { + label: string; + previousValue: number; + currentValue: number; + }[] + | undefined; + }[] = [ + { + key: 'specifications', + label: 'Specifications', + currentValue: specifications ?? defaultValues.specifications, + defaultValue: defaultValues.specifications, + dirtyValue: dirtyFieldsRecord?.specifications, + children: + currentTargetNodesCount > defaultValues.specifications.targetNodesCount + ? [ + { + label: 'Target Nodes Count', + previousValue: defaultValues.specifications.targetNodesCount, + currentValue: currentTargetNodesCount, + }, + ] + : undefined, + }, + { + key: 'costAndDuration', + label: 'Duration', + currentValue: costAndDuration ?? defaultValues.costAndDuration, + defaultValue: defaultValues.costAndDuration, + dirtyValue: dirtyFieldsRecord?.costAndDuration, + }, + { + key: 'deployment', + label: 'Deployment', + currentValue: deployment ?? defaultValues.deployment, + defaultValue: defaultValues.deployment, + dirtyValue: dirtyFieldsRecord?.deployment, + }, + ]; + + if (job.resources.jobType === JobType.Native && defaultPlugins) { + baseSteps.push({ + key: 'plugins', + label: 'Plugins', + currentValue: plugins ?? defaultPlugins, + defaultValue: defaultPlugins, + dirtyValue: dirtyFieldsRecord?.plugins, + }); + } + + return baseSteps.map(({ key, label, currentValue, defaultValue, dirtyValue, children }) => { + const isDirty = hasDirtyFields(dirtyValue); + const hasChanged = !isEqual(currentValue, defaultValue); + + return { + key, + label, + modified: isDirty && hasChanged, + children: children && isDirty && hasChanged ? children : undefined, + }; + }); + }, [ + costAndDuration, + defaultValues, + deployment, + dirtyFields, + job.resources.jobType, + plugins, + specifications, + currentTargetNodesCount, + ]); const hasModifiedSteps = stepsStatus.some((step) => step.modified); + // Init + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + useEffect(() => { onHasModifiedStepsChange?.(hasModifiedSteps); }, [hasModifiedSteps, onHasModifiedStepsChange]); @@ -147,7 +188,7 @@ export default function ReviewAndConfirm({ return (
- +
Total Amount Due
@@ -159,7 +200,7 @@ export default function ReviewAndConfirm({
- +
diff --git a/src/components/job/JobFileVolumesSection.tsx b/src/components/job/JobFileVolumesSection.tsx index e3687ff1..39866269 100644 --- a/src/components/job/JobFileVolumesSection.tsx +++ b/src/components/job/JobFileVolumesSection.tsx @@ -1,7 +1,36 @@ import { getShortAddressOrHash } from '@lib/utils'; +import ContextMenuWithTrigger from '@shared/ContextMenuWithTrigger'; import { SmallTag } from '@shared/SmallTag'; +import toast from 'react-hot-toast'; export default function JobFileVolumesSection({ obj }: { obj: Record }) { + const handleDownloadFile = (fileName: string, content: string) => { + const sanitizedFileName = fileName + .trim() + .replace(/\s+/g, '_') + .replace(/[^\w.-]/g, ''); + + const finalFileName = sanitizedFileName.length > 0 ? sanitizedFileName : 'downloaded_file'; + + try { + const fileBlob = new Blob([content]); + const downloadUrl = URL.createObjectURL(fileBlob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = finalFileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(downloadUrl); + + toast.success('File downloaded successfully.', { + duration: 2000, + }); + } catch (error) { + toast.error('Failed to download file.'); + } + }; + return (
{Object.entries(obj).map(([key, value]) => ( @@ -19,6 +48,32 @@ export default function JobFileVolumesSection({ obj }: { obj: Record{getShortAddressOrHash(value.mounting_point, 16, true)}
+ + { + try { + await navigator.clipboard.writeText(value.content); + toast.success('File contents copied to clipboard.', { + duration: 2000, + }); + } catch (error) { + toast.error('Failed to copy file contents to clipboard.'); + } + }, + }, + { + key: 'download', + label: 'Download file', + onPress: () => { + handleDownloadFile(key, value.content); + }, + }, + ]} + />
))}
diff --git a/src/components/job/JobInstances.tsx b/src/components/job/JobInstances.tsx index ba7be5d7..a1106dea 100644 --- a/src/components/job/JobInstances.tsx +++ b/src/components/job/JobInstances.tsx @@ -143,45 +143,55 @@ export default function JobInstances({
- {instance.plugins.map((plugin, index, array) => { - return ( -
- {/* Tree Line */} -
-
-
- - {index === array.length - 1 && ( -
- )} -
- - - {plugin.instance} - - - { - onInstanceCommand('RESTART', plugin.signature, plugin.instance); + {instance.plugins + .sort((a, b) => a.instance.localeCompare(b.instance)) + .map((plugin, index, array) => { + return ( +
+ {/* Tree Line */} +
+
+
+ + {index === array.length - 1 && ( +
+ )} +
+ + + {plugin.instance} + + + { + onInstanceCommand( + 'RESTART', + plugin.signature, + plugin.instance, + ); + }, }, - }, - { - key: 'stop', - label: 'Stop', - onPress: () => { - onInstanceCommand('STOP', plugin.signature, plugin.instance); + { + key: 'stop', + label: 'Stop', + onPress: () => { + onInstanceCommand( + 'STOP', + plugin.signature, + plugin.instance, + ); + }, }, - }, - ]} - isDisabled={isActionOngoing} - /> -
- ); - })} + ]} + isDisabled={isActionOngoing} + /> +
+ ); + })}
diff --git a/src/components/job/JobKeyValueSection.tsx b/src/components/job/JobKeyValueSection.tsx index bc7226e2..ee4c60e1 100644 --- a/src/components/job/JobKeyValueSection.tsx +++ b/src/components/job/JobKeyValueSection.tsx @@ -2,9 +2,18 @@ import { getShortAddressOrHash, isKeySecret } from '@lib/utils'; import { CopyableValue } from '@shared/CopyableValue'; import SecretValueToggle from '@shared/jobs/SecretValueToggle'; import { SmallTag } from '@shared/SmallTag'; +import { isEmpty } from 'lodash'; import { useEffect, useState } from 'react'; -export default function JobKeyValueSection({ obj }: { obj: Record }) { +export default function JobKeyValueSection({ + obj, + labels = ['KEY', 'VALUE'], + displayShortValues = true, +}: { + obj: Record; + labels?: [string, string]; + displayShortValues?: boolean; +}) { const [isFieldSecret, setFieldSecret] = useState<{ [id: string]: boolean }>({}); useEffect(() => { @@ -18,23 +27,31 @@ export default function JobKeyValueSection({ obj }: { obj: Record } }); }, [obj]); + if (isEmpty(obj)) { + return <>—; + } + return (
{Object.entries(obj).map(([key, value]) => (
-
KEY
+
{labels[0]}
{key}
-
VALUE
+
{labels[1]}
- {isFieldSecret[key] ? '•••••••••' : getShortAddressOrHash(value, 16, true)} + {isFieldSecret[key] + ? '•••••••••' + : displayShortValues + ? getShortAddressOrHash(value, 16, true) + : value} {isKeySecret(key) && ( !NATIVE_PLUGIN_DEFAULT_RESPONSE_KEYS.includes(key as keyof JobConfig)) + .map(([key, value]) => [key, JSON.stringify(value)]), + ); + + return ( + <> + + + } + /> + + ); +} diff --git a/src/components/job/config/ConfigSectionTitle.tsx b/src/components/job/config/ConfigSectionTitle.tsx index 5209eea1..a93d5c71 100644 --- a/src/components/job/config/ConfigSectionTitle.tsx +++ b/src/components/job/config/ConfigSectionTitle.tsx @@ -1,9 +1,15 @@ import { SmallTag } from '@shared/SmallTag'; -export default function ConfigSectionTitle({ title }: { title: string }) { +export default function ConfigSectionTitle({ + title, + variant = 'blue', +}: { + title: string; + variant?: 'blue' | 'green' | 'purple'; +}) { return (
- +
{title}
diff --git a/src/components/job/config/JobConfigurations.tsx b/src/components/job/config/JobConfigurations.tsx deleted file mode 100644 index c7908e07..00000000 --- a/src/components/job/config/JobConfigurations.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { SelectItem } from '@heroui/select'; -import { getShortAddressOrHash } from '@lib/utils'; -import { BorderedCard } from '@shared/cards/BorderedCard'; -import { CopyableValue } from '@shared/CopyableValue'; -import ItemWithBoldValue from '@shared/jobs/ItemWithBoldValue'; -import StyledSelect from '@shared/StyledSelect'; -import { JobConfig } from '@typedefs/deeployApi'; -import { JobType, RunningJobWithResources } from '@typedefs/deeploys'; -import _, { isEmpty } from 'lodash'; -import { useEffect, useState } from 'react'; -import JobDynamicEnvSection from '../JobDynamicEnvSection'; -import JobFileVolumesSection from '../JobFileVolumesSection'; -import JobKeyValueSection from '../JobKeyValueSection'; -import JobSimpleTagsSection from '../JobSimpleTagsSection'; -import ConfigCAR from './ConfigCAR'; -import ConfigSectionTitle from './ConfigSectionTitle'; -import ConfigWAR from './ConfigWAR'; - -type PluginConfig = { - signature: string; - value: JobConfig; -}; - -export default function JobConfigurations({ job }: { job: RunningJobWithResources }) { - const tags = !job.jobTags ? [] : job.jobTags.filter((tag) => tag !== ''); - - const pluginConfigs: PluginConfig[] = _(job.instances) - .map((instance) => instance.plugins) - .flatten() - .map((plugin) => { - return { - signature: plugin.signature, - value: plugin.instance_conf, - }; - }) - .uniqBy('signature') - .sortBy('signature') - .value(); - - const [pluginConfig, setPluginConfig] = useState(pluginConfigs[0]!); - - const config = pluginConfig.value; - - useEffect(() => { - console.log('Config', pluginConfig); - }, [pluginConfig]); - - return ( - -
-
-
Configurations
- -
- { - const selectedKey = Array.from(keys)[0] as string; - setPluginConfig(pluginConfigs.find((config) => config.signature === selectedKey)!); - }} - placeholder="Select a plugin" - > - {pluginConfigs.map((item) => ( - -
-
{item.signature}
-
-
- ))} -
-
-
- -
-
- - - - - {!!config.TUNNEL_ENGINE_ENABLED && ( - <> - - - {getShortAddressOrHash(config.CLOUDFLARE_TOKEN, 4, false)} - - ) : ( - '—' - ) - } - /> - - )} - - - -
- - {/* Nodes */} - - -
- - } - /> - - getShortAddressOrHash(addr, 8, true) as string)} - type="col" - copyable - /> - ) - } - /> -
- - {/* Worker App Runner */} - {pluginConfig.signature === 'WORKER_APP_RUNNER' && ( - - )} - - {/* Container App Runner */} - {pluginConfig.signature === 'CONTAINER_APP_RUNNER' && ( - - )} - - {/* Service */} - {job.resources.jobType === JobType.Service && ( - <> - - - - - )} - - {/* Variables */} - - -
-
- } - /> - - {job.resources.jobType !== JobType.Native && ( - - ) - } - /> - )} -
- -
- } - /> - - {job.resources.jobType === JobType.Generic && ( - - } - /> - )} -
-
-
-
-
- ); -} diff --git a/src/components/job/config/JobDeploymentSection.tsx b/src/components/job/config/JobDeploymentSection.tsx new file mode 100644 index 00000000..d01decd1 --- /dev/null +++ b/src/components/job/config/JobDeploymentSection.tsx @@ -0,0 +1,127 @@ +import { getShortAddressOrHash } from '@lib/utils'; +import { BorderedCard } from '@shared/cards/BorderedCard'; +import { CopyableValue } from '@shared/CopyableValue'; +import ItemWithBoldValue from '@shared/jobs/ItemWithBoldValue'; +import { JobType, RunningJobWithResources } from '@typedefs/deeploys'; +import { isEmpty } from 'lodash'; +import JobKeyValueSection from '../JobKeyValueSection'; +import JobSimpleTagsSection from '../JobSimpleTagsSection'; +import ConfigSectionTitle from './ConfigSectionTitle'; + +export default function JobDeploymentSection({ job }: { job: RunningJobWithResources }) { + const tags = !job.jobTags ? [] : job.jobTags.filter((tag) => tag !== ''); + + const config = job.config; + + const pipelineData = job.pipelineData; + + return ( + +
+
Deployment
+ +
+ {/* Nodes */} + + +
+ + } + /> + + getShortAddressOrHash(addr, 8, true) as string)} + type="col" + copyable + /> + ) + } + /> +
+ + {/* Tunneling */} + + +
+ + + + + {!!config.TUNNEL_ENGINE_ENABLED && ( + <> + + + {config.TUNNEL_ENGINE === 'cloudflare' ? ( + + {getShortAddressOrHash(config.CLOUDFLARE_TOKEN, 4, false)} + + ) : ( + '—' + ) + } + /> + ) : ( + + {getShortAddressOrHash(config.NGROK_AUTH_TOKEN, 4, false)} + + ) : ( + '—' + ) + } + /> + )} + + {config.NGROK_EDGE_LABEL && ( + + )} + + )} +
+ + {/* Pipeline */} + {job.resources.jobType === JobType.Native && ( + <> + + +
+ + + + + ) + } + /> +
+ + )} +
+
+
+ ); +} diff --git a/src/components/job/config/JobPluginsSection.tsx b/src/components/job/config/JobPluginsSection.tsx new file mode 100644 index 00000000..68bc0d89 --- /dev/null +++ b/src/components/job/config/JobPluginsSection.tsx @@ -0,0 +1,166 @@ +import { SelectItem } from '@heroui/select'; +import { isGenericPlugin } from '@lib/deeploy-utils'; +import { BorderedCard } from '@shared/cards/BorderedCard'; +import ItemWithBoldValue from '@shared/jobs/ItemWithBoldValue'; +import StyledSelect from '@shared/StyledSelect'; +import { JobConfig } from '@typedefs/deeployApi'; +import { JobType, RunningJobWithResources } from '@typedefs/deeploys'; +import _, { isEmpty } from 'lodash'; +import { useState } from 'react'; +import JobDynamicEnvSection from '../JobDynamicEnvSection'; +import JobFileVolumesSection from '../JobFileVolumesSection'; +import JobKeyValueSection from '../JobKeyValueSection'; +import ConfigCAR from './ConfigCAR'; +import ConfigNative from './ConfigNative'; +import ConfigSectionTitle from './ConfigSectionTitle'; +import ConfigWAR from './ConfigWAR'; + +type PluginConfig = { + signature: string; + value: JobConfig; +}; + +export default function JobPluginsSection({ job }: { job: RunningJobWithResources }) { + const pluginConfigs: PluginConfig[] = _(job.instances) + .map((instance) => instance.plugins) + .flatten() + .map((plugin) => { + return { + signature: plugin.signature, + value: plugin.instance_conf, + }; + }) + .uniqBy('signature') + .sortBy('signature') + .value(); + + const [pluginConfig, setPluginConfig] = useState(pluginConfigs[0]!); + + // useEffect(() => { + // console.log('Plugin', pluginConfig); + // }, [pluginConfig]); + + const config = pluginConfig.value; + + return ( + +
+
+
Plugins
+ +
+ { + const selectedKey = Array.from(keys)[0] as string; + setPluginConfig(pluginConfigs.find((config) => config.signature === selectedKey)!); + }} + placeholder="Select a plugin" + > + {pluginConfigs.map((item) => ( + +
+
{item.signature}
+
+
+ ))} +
+
+
+ +
+ {/* Native Plugin */} + {!isGenericPlugin(pluginConfig.signature) && } + + {/* Worker App Runner */} + {pluginConfig.signature === 'WORKER_APP_RUNNER' && ( + + )} + + {/* Container App Runner */} + {pluginConfig.signature === 'CONTAINER_APP_RUNNER' && ( + + )} + + {/* Service */} + {job.resources.jobType === JobType.Service && ( + <> + + + + + )} + + {/* Port Mapping */} + {isGenericPlugin(pluginConfig.signature) && !isEmpty(pluginConfig.value.CONTAINER_RESOURCES.ports) && ( + <> + + + + + )} + + {/* Variables & Policies */} + {isGenericPlugin(pluginConfig.signature) && ( + <> + + +
+
+ } + /> + + {job.resources.jobType !== JobType.Native && ( + + ) + } + /> + )} +
+ +
+ } + /> + + {(job.resources.jobType === JobType.Generic || isGenericPlugin(pluginConfig.signature)) && ( + + ) + } + /> + )} +
+
+ + + +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/src/components/project/ProjectOverview.tsx b/src/components/project/ProjectOverview.tsx index 53fb394f..3ff08aba 100644 --- a/src/components/project/ProjectOverview.tsx +++ b/src/components/project/ProjectOverview.tsx @@ -39,7 +39,7 @@ export default function ProjectOverview({ const { fetchApps, projectOverviewTab, setProjectOverviewTab } = useDeploymentContext() as DeploymentContextType; const [runningJobsWithResources, setRunningJobsWithResources] = useState([]); - console.log('[ProjectOverview]', { draftJobs }); + // console.log('[ProjectOverview]', { draftJobs }); useEffect(() => { const runningJobsWithResources: RunningJobWithResources[] = _(runningJobs) @@ -55,7 +55,7 @@ export default function ProjectOverview({ .filter((job) => job !== undefined) .value(); - console.log('[ProjectOverview] runningJobsWithResources', runningJobsWithResources); + console.log('[ProjectOverview]', { runningJobsWithResources, draftJobs }); setRunningJobsWithResources(runningJobsWithResources); }, [runningJobs]); diff --git a/src/data/containerResources.tsx b/src/data/containerResources.tsx index b6c26250..7bcbbbbe 100644 --- a/src/data/containerResources.tsx +++ b/src/data/containerResources.tsx @@ -1,6 +1,4 @@ import { JobType } from '@typedefs/deeploys'; -import { DiMysql } from 'react-icons/di'; -import { SiMongodb, SiPostgresql } from 'react-icons/si'; export type ContainerOrWorkerType = { id: number; @@ -21,7 +19,11 @@ export type Service = ContainerOrWorkerType & { port?: number; image?: string; dbSystem?: string; - icon?: React.ReactNode; + tag?: { + text: string; + bgClass: string; + textClass: string; + }; inputs?: { key: string; label: string }[]; }; @@ -46,33 +48,6 @@ export type RunningJobResources = { jobType: JobType; }; -const getPostgresTag = () => ( -
-
- -
PostgreSQL
-
-
-); - -const getMySQLTag = () => ( -
-
- -
MySQL
-
-
-); - -const getMongoDBTag = () => ( -
-
- -
MongoDB
-
-
-); - export const genericContainerTypes: ContainerOrWorkerType[] = [ { id: 1, @@ -278,7 +253,7 @@ export const serviceContainerTypes: Service[] = [ port: 5432, image: 'postgres:17', dbSystem: 'PostgreSQL', - icon: getPostgresTag(), + tag: { text: 'PostgreSQL', bgClass: 'bg-blue-100', textClass: 'text-blue-600' }, inputs: [{ key: 'POSTGRES_PASSWORD', label: 'PostgreSQL Password' }], }, { @@ -297,7 +272,7 @@ export const serviceContainerTypes: Service[] = [ port: 5432, image: 'postgres:17', dbSystem: 'PostgreSQL', - icon: getPostgresTag(), + tag: { text: 'PostgreSQL', bgClass: 'bg-blue-100', textClass: 'text-blue-600' }, inputs: [{ key: 'POSTGRES_PASSWORD', label: 'PostgreSQL Password' }], }, { @@ -316,7 +291,7 @@ export const serviceContainerTypes: Service[] = [ port: 3306, image: 'mysql', dbSystem: 'MySQL', - icon: getMySQLTag(), + tag: { text: 'MySQL', bgClass: 'bg-orange-100', textClass: 'text-orange-600' }, inputs: [{ key: 'MYSQL_ROOT_PASSWORD', label: 'MySQL Root Password' }], }, { @@ -335,7 +310,7 @@ export const serviceContainerTypes: Service[] = [ port: 3306, image: 'mysql', dbSystem: 'MySQL', - icon: getMySQLTag(), + tag: { text: 'MySQL', bgClass: 'bg-orange-100', textClass: 'text-orange-600' }, inputs: [{ key: 'MYSQL_ROOT_PASSWORD', label: 'MySQL Root Password' }], }, { @@ -354,7 +329,7 @@ export const serviceContainerTypes: Service[] = [ port: 27017, image: 'mongodb', dbSystem: 'MongoDB', - icon: getMongoDBTag(), + tag: { text: 'MongoDB', bgClass: 'bg-green-100', textClass: 'text-green-600' }, inputs: [ { key: 'MONGO_INITDB_ROOT_USERNAME', label: 'MongoDB Root Username' }, { key: 'MONGO_INITDB_ROOT_PASSWORD', label: 'MongoDB Root Password' }, @@ -376,7 +351,7 @@ export const serviceContainerTypes: Service[] = [ port: 27017, image: 'mongodb', dbSystem: 'MongoDB', - icon: getMongoDBTag(), + tag: { text: 'MongoDB', bgClass: 'bg-green-100', textClass: 'text-green-600' }, inputs: [ { key: 'MONGO_INITDB_ROOT_USERNAME', label: 'MongoDB Root Username' }, { key: 'MONGO_INITDB_ROOT_PASSWORD', label: 'MongoDB Root Password' }, diff --git a/src/data/default-values/customParams.ts b/src/data/default-values/customParams.ts deleted file mode 100644 index 10225aa3..00000000 --- a/src/data/default-values/customParams.ts +++ /dev/null @@ -1 +0,0 @@ -export const pluginSignaturesCustomParams = {}; diff --git a/src/index.css b/src/index.css index 3dae5b17..66fffdfd 100644 --- a/src/index.css +++ b/src/index.css @@ -112,6 +112,10 @@ layer(base); @apply text-sm font-medium; } +.section-title { + @apply flex min-h-[28px] items-center text-[17px] font-semibold; +} + /* Scrollbar */ *::-webkit-scrollbar { width: 8px; @@ -162,3 +166,14 @@ button { ul { list-style: inside; } + +/* CodeMirror */ +.cm-editor { + outline: none !important; +} + +.cm-editor.cm-focused { + outline: none !important; + border: none !important; + box-shadow: none !important; +} diff --git a/src/lib/api/backend.tsx b/src/lib/api/backend.tsx index 2a7ccd4f..8750f88c 100644 --- a/src/lib/api/backend.tsx +++ b/src/lib/api/backend.tsx @@ -1,5 +1,5 @@ import { config } from '@lib/config'; -import { InvoiceDraft } from '@typedefs/general'; +import { InvoiceDraft, PublicProfileInfo } from '@typedefs/general'; import axios from 'axios'; import * as types from 'typedefs/blockchain'; @@ -84,6 +84,11 @@ export const downloadBurnReport = async (start: string, end: string) => { setTimeout(() => URL.revokeObjectURL(urlObj), 0); }; +export const getBrandingPlatforms = async () => _doGet('/branding/get-platforms'); + +export const getProfilePicture = async (address: types.EthAddress) => + _doGet(`/branding/get-brand-logo?address=${address}`); + // ***** // POST // ***** @@ -97,6 +102,20 @@ export const accessAuth = (params: { message: string; signature: string }) => export const initSumsubSession = (type: 'individual' | 'company') => _doPost('/sumsub/init/Kyc', { type }); +export const getPublicProfileInfo = async (address: types.EthAddress) => + _doPost('/branding/get-brands', { brandAddresses: [address] }); + +export const uploadProfileImage = async (file: File) => { + const formData = new FormData(); + formData.append('logo', file); + + return _doPost('/branding/edit-logo', formData, { + 'Content-Type': 'multipart/form-data', + }); +}; + +export const updatePublicProfileInfo = async (info: PublicProfileInfo) => _doPost('/branding/edit', info); + // ***** // INTERNAL HELPERS // ***** @@ -112,11 +131,11 @@ async function _doGet(endpoint: string) { return data.data; } -async function _doPost(endpoint: string, body: any) { +async function _doPost(endpoint: string, body: any, headers?: Record) { const { data } = await axiosDapp.post<{ data: T; error: string; - }>(endpoint, body); + }>(endpoint, body, { headers }); if (data.error) { throw new Error(data.error); } diff --git a/src/lib/api/deeploy.ts b/src/lib/api/deeploy.ts index 1699a6b7..632d934b 100644 --- a/src/lib/api/deeploy.ts +++ b/src/lib/api/deeploy.ts @@ -3,6 +3,10 @@ import { EthAddress } from '@typedefs/blockchain'; import { DeeployDefaultResponse, GetAppsResponse } from '@typedefs/deeployApi'; import axios from 'axios'; +if (!config?.deeployUrl && process.env.NODE_ENV === 'development') { + console.error('Missing .env file'); +} + export const createPipeline = (request: { EE_ETH_SIGN: EthAddress; EE_ETH_SENDER: EthAddress; diff --git a/src/lib/contexts/deployment/deployment-provider.tsx b/src/lib/contexts/deployment/deployment-provider.tsx index 7dfb2980..699841f1 100644 --- a/src/lib/contexts/deployment/deployment-provider.tsx +++ b/src/lib/contexts/deployment/deployment-provider.tsx @@ -4,7 +4,7 @@ import { getDevAddress, isUsingDevAddress } from '@lib/config'; import { buildDeeployMessage, generateDeeployNonce } from '@lib/deeploy-utils'; import { SigningModal } from '@shared/SigningModal'; import { EthAddress, R1Address } from '@typedefs/blockchain'; -import { Apps, AppsPlugin, DeeploySpecs, JobConfig } from '@typedefs/deeployApi'; +import { Apps, AppsPlugin, DeeploySpecs, JobConfig, PipelineData } from '@typedefs/deeployApi'; import { JobType, ProjectPage, RunningJob, RunningJobWithDetails } from '@typedefs/deeploys'; import _ from 'lodash'; import { useRef, useState } from 'react'; @@ -128,7 +128,7 @@ export const DeploymentProvider = ({ children }) => { functionName: 'getActiveJobs', }); - // console.log('[DeploymentProvider] Smart contract jobs', runningJobs); + console.log('[DeploymentProvider] getActiveJobs', runningJobs); const runningJobsWithDetails: RunningJobWithDetails[] = formatRunningJobsWithDetails(runningJobs, appsOverride); return runningJobsWithDetails; @@ -157,6 +157,7 @@ export const DeploymentProvider = ({ children }) => { last_config: string; is_deeployed: boolean; deeploy_specs: DeeploySpecs; + pipeline_data: PipelineData; alias: string; instances: { nodeAddress: R1Address; @@ -177,6 +178,7 @@ export const DeploymentProvider = ({ children }) => { last_config: string; is_deeployed: boolean; deeploy_specs: DeeploySpecs; + pipeline_data: PipelineData; alias: string; } | undefined; @@ -226,6 +228,7 @@ export const DeploymentProvider = ({ children }) => { .map((appWithInstances) => { const alias: string = appWithInstances.alias; const specs = appWithInstances.deeploy_specs; + const pipelineData = appWithInstances.pipeline_data; const jobId = specs.job_id; if (!appWithInstances.instances.length) { @@ -242,7 +245,7 @@ export const DeploymentProvider = ({ children }) => { return null; } - return { + const jobWithDetails: RunningJobWithDetails = { alias, projectName: specs.project_name, allowReplicationInTheWild: specs.allow_replication_in_the_wild, @@ -251,8 +254,15 @@ export const DeploymentProvider = ({ children }) => { nodes: appWithInstances.instances.map((instance) => instance.nodeAddress), instances: appWithInstances.instances, config, + pipelineData, ...job, }; + + if (specs.job_config?.pipeline_params) { + jobWithDetails.pipelineParams = specs.job_config.pipeline_params; + } + + return jobWithDetails; }) .filter((job) => job !== null) .value(); diff --git a/src/lib/deeploy-utils.ts b/src/lib/deeploy-utils.ts index 1fef1747..0814e2f2 100644 --- a/src/lib/deeploy-utils.ts +++ b/src/lib/deeploy-utils.ts @@ -8,6 +8,7 @@ import { serviceContainerTypes, } from '@data/containerResources'; import { PLUGIN_SIGNATURE_TYPES } from '@data/pluginSignatureTypes'; +import { JobConfig } from '@typedefs/deeployApi'; import { DraftJob, GenericDraftJob, @@ -23,7 +24,15 @@ import { ServiceJobDeployment, ServiceJobSpecifications, } from '@typedefs/deeploys'; -import { GenericSecondaryPlugin, NativeSecondaryPlugin, SecondaryPluginType } from '@typedefs/steps/deploymentStepTypes'; +import { + BasePluginType, + ContainerDeploymentType, + GenericPlugin, + NativePlugin, + PluginType, + PortMappingEntry, + WorkerDeploymentType, +} from '@typedefs/steps/deploymentStepTypes'; import { addDays, addHours, differenceInDays, differenceInHours } from 'date-fns'; import _ from 'lodash'; import { FieldValues, UseFieldArrayAppend, UseFieldArrayRemove } from 'react-hook-form'; @@ -37,6 +46,15 @@ export const KYB_TAG = 'IS_KYB'; export const KYC_TAG = '!IS_KYB'; export const DC_TAG = 'DC:*'; +export const NATIVE_PLUGIN_DEFAULT_RESPONSE_KEYS: (keyof JobConfig)[] = [ + 'CHAINSTORE_PEERS', + 'CLOUDFLARE_TOKEN', + 'INSTANCE_ID', + 'PORT', + 'TUNNEL_ENGINE_ENABLED', + 'NGROK_USE_API', +]; + export const getDiscountPercentage = (_paymentMonthsCount: number): number => { // Disabled for now return 0; @@ -101,7 +119,9 @@ export const getContainerOrWorkerType = (jobType: JobType, specifications: JobSp }; export const getGpuType = (specifications: GenericJobSpecifications | NativeJobSpecifications): GpuType | undefined => { - return specifications.gpuType ? gpuTypes.find((type) => type.name === specifications.gpuType) : undefined; + return specifications.gpuType && specifications.gpuType !== '' + ? gpuTypes.find((type) => type.name === specifications.gpuType) + : undefined; }; export const downloadDataAsJson = (data: any, filename: string) => { @@ -170,12 +190,23 @@ export const formatFileVolumes = (fileVolumes: { name: string; mountingPoint: st return formatted; }; -export const formatContainerResources = (containerOrWorkerType: ContainerOrWorkerType, ports?: Record) => { - return { +export const formatContainerResources = (containerOrWorkerType: ContainerOrWorkerType, portsArray: Array) => { + const baseResources: { cpu: number; memory: string; ports?: Record } = { cpu: containerOrWorkerType.cores, memory: `${containerOrWorkerType.ram}g`, - ...(ports && Object.keys(ports).length > 0 && { ports }), }; + + if (portsArray.length > 0) { + const ports = {}; + + portsArray.forEach((port) => { + ports[port.hostPort.toString()] = port.containerPort; + }); + + baseResources.ports = ports; + } + + return baseResources; }; export const formatNodes = (targetNodes: { address: string }[]): string[] => { @@ -189,6 +220,14 @@ export const formatTargetNodesCount = (targetNodes: string[], specificationsTarg return targetNodes.length > 0 ? 0 : specificationsTargetNodesCount; }; +const formatPort = (port: number | string | undefined) => { + if (!port || port === '') { + return null; + } + + return Number(port); +}; + export const formatJobTags = (specifications: JobSpecifications) => { const countries = specifications.nodesCountries.map((country) => `CT:${country}`).join('||'); return [...specifications.jobTags, countries].filter((tag) => tag !== ''); @@ -209,7 +248,7 @@ export const formatServiceDraftJobPayload = (job: ServiceDraftJob) => { return formatServiceJobPayload(containerType, job.specifications, job.deployment); }; -const formatGenericJobVariables = (plugin: GenericSecondaryPlugin) => { +const formatGenericJobVariables = (plugin: GenericPlugin) => { return { envVars: formatEnvVars(plugin.envVars), dynamicEnvVars: formatDynamicEnvVars(plugin.dynamicEnvVars), @@ -218,41 +257,44 @@ const formatGenericJobVariables = (plugin: GenericSecondaryPlugin) => { }; }; -const formatNativeJobPluginSignature = (plugin: NativeSecondaryPlugin) => { +const formatNativeJobPluginSignature = (plugin: NativePlugin) => { return plugin.pluginSignature === PLUGIN_SIGNATURE_TYPES[PLUGIN_SIGNATURE_TYPES.length - 1] ? plugin.customPluginSignature : plugin.pluginSignature; }; -const formatNativeJobCustomParams = (pluginConfig: any, plugin: NativeSecondaryPlugin) => { +const formatNativeJobCustomParams = (plugin: NativePlugin) => { + const customParams: Record = {}; + if (!_.isEmpty(plugin.customParams)) { plugin.customParams.forEach((param) => { if (param.key) { - pluginConfig[param.key] = parseIfJson(param.value); + customParams[param.key] = parseIfJson(param.value); } }); } + + return customParams; }; export const formatGenericPluginConfigAndSignature = ( resources: { cpu: number; memory: string; - ports?: Record; + ports?: Record; }, - plugin: GenericSecondaryPlugin, + plugin: GenericPlugin, ) => { const { envVars, dynamicEnvVars, volumes, fileVolumes } = formatGenericJobVariables(plugin); let pluginSignature: string; const pluginConfig: any = { CONTAINER_RESOURCES: resources, - PORT: plugin.port, + PORT: formatPort(plugin.port), // Tunneling TUNNEL_ENGINE: 'cloudflare', CLOUDFLARE_TOKEN: plugin.tunnelingToken || null, TUNNEL_ENGINE_ENABLED: plugin.enableTunneling === 'True', - NGROK_USE_API: true, // Variables ENV: envVars, DYNAMIC_ENV: dynamicEnvVars, @@ -263,35 +305,53 @@ export const formatGenericPluginConfigAndSignature = ( IMAGE_PULL_POLICY: plugin.imagePullPolicy.toLowerCase(), }; - if (plugin.deploymentType.type === 'container') { + if (plugin.deploymentType.pluginType === PluginType.Container) { pluginSignature = 'CONTAINER_APP_RUNNER'; + const containerDeploymentType: ContainerDeploymentType = plugin.deploymentType as ContainerDeploymentType; - pluginConfig.IMAGE = plugin.deploymentType.containerImage; + pluginConfig.IMAGE = containerDeploymentType.containerImage; pluginConfig.CR_DATA = { - SERVER: plugin.deploymentType.containerRegistry, + SERVER: containerDeploymentType.containerRegistry, }; - if (plugin.deploymentType.crVisibility === 'Private') { - pluginConfig.CR_DATA.USERNAME = plugin.deploymentType.crUsername; - pluginConfig.CR_DATA.PASSWORD = plugin.deploymentType.crPassword; + if (containerDeploymentType.crVisibility === 'Private') { + pluginConfig.CR_DATA.USERNAME = containerDeploymentType.crUsername; + pluginConfig.CR_DATA.PASSWORD = containerDeploymentType.crPassword; } } else { pluginSignature = 'WORKER_APP_RUNNER'; + const workerDeploymentType: WorkerDeploymentType = plugin.deploymentType as WorkerDeploymentType; - pluginConfig.IMAGE = plugin.deploymentType.image; - pluginConfig.BUILD_AND_RUN_COMMANDS = plugin.deploymentType.workerCommands.map((entry) => entry.command); + pluginConfig.IMAGE = workerDeploymentType.image; + pluginConfig.BUILD_AND_RUN_COMMANDS = workerDeploymentType.workerCommands.map((entry) => entry.command); pluginConfig.VCS_DATA = { - REPO_URL: plugin.deploymentType.repositoryUrl, - USERNAME: plugin.deploymentType.username || null, - TOKEN: plugin.deploymentType.accessToken || null, + REPO_URL: workerDeploymentType.repositoryUrl, + USERNAME: workerDeploymentType.username || null, + TOKEN: workerDeploymentType.accessToken || null, }; } return { pluginConfig, pluginSignature }; }; +const formatNativePlugin = (plugin: NativePlugin) => { + const pluginConfig: any = { + plugin_signature: formatNativeJobPluginSignature(plugin), + + // Tunneling + PORT: formatPort(plugin.port), + CLOUDFLARE_TOKEN: plugin.tunnelingToken || null, + TUNNEL_ENGINE_ENABLED: plugin.enableTunneling === 'True', + + // Custom Parameters + ...formatNativeJobCustomParams(plugin), + }; + + return pluginConfig; +}; + export const formatGenericJobPayload = ( containerType: ContainerOrWorkerType, specifications: GenericJobSpecifications, @@ -303,7 +363,7 @@ export const formatGenericJobPayload = ( const spareNodes = formatNodes(deployment.spareNodes); const { pluginConfig, pluginSignature } = formatGenericPluginConfigAndSignature( - formatContainerResources(containerType, deployment.deploymentType.ports), + formatContainerResources(containerType, deployment.ports), deployment, ); @@ -343,7 +403,7 @@ export const formatNativeJobPayload = ( } }); - const nodeResources = formatContainerResources(workerType, undefined); + const nodeResources = formatContainerResources(workerType, []); const targetNodes = formatNodes(deployment.targetNodes); const targetNodesCount = formatTargetNodesCount(targetNodes, specifications.targetNodesCount); @@ -351,60 +411,28 @@ export const formatNativeJobPayload = ( const nonce = generateDeeployNonce(); - // Primary plugin configuration - const primaryPluginConfig: any = { - plugin_signature: formatNativeJobPluginSignature(deployment), - PORT: deployment.port, - CLOUDFLARE_TOKEN: deployment.tunnelingToken || null, - TUNNEL_ENGINE_ENABLED: deployment.enableTunneling === 'True', - NGROK_USE_API: true, - }; - - formatNativeJobCustomParams(primaryPluginConfig, deployment); + // Build plugins array + const plugins = deployment.plugins.map((plugin) => { + if (plugin.basePluginType === BasePluginType.Generic) { + const secondaryPluginNodeResources = formatContainerResources(workerType, (plugin as GenericPlugin).ports); - // Build plugins array starting with the primary plugin - const plugins = [primaryPluginConfig]; + const { pluginConfig, pluginSignature } = formatGenericPluginConfigAndSignature( + secondaryPluginNodeResources, + plugin as GenericPlugin, + ); - // Add secondary plugins if they exist - if (deployment.secondaryPlugins.length) { - const secondaryPluginConfigs = deployment.secondaryPlugins.map((plugin) => { - if (plugin.secondaryPluginType === SecondaryPluginType.Generic) { - const { pluginConfig, pluginSignature } = formatGenericPluginConfigAndSignature( - nodeResources, - plugin as GenericSecondaryPlugin, - ); - - return { - plugin_signature: pluginSignature, - ...pluginConfig, - }; - } - - if (plugin.secondaryPluginType === SecondaryPluginType.Native) { - const nativePlugin = plugin as NativeSecondaryPlugin; - - const nativePluginConfig: any = { - plugin_signature: formatNativeJobPluginSignature(nativePlugin), - PORT: nativePlugin.port, - CLOUDFLARE_TOKEN: nativePlugin.tunnelingToken || null, - TUNNEL_ENGINE_ENABLED: nativePlugin.enableTunneling === 'True', - NGROK_USE_API: true, - }; - - if (!_.isEmpty(nativePlugin.customParams)) { - nativePlugin.customParams.forEach((param) => { - if (param.key) { - nativePluginConfig[param.key] = param.value; - } - }); - } + return { + plugin_signature: pluginSignature, + ...pluginConfig, + }; + } - return nativePluginConfig; - } - }); + if (plugin.basePluginType === BasePluginType.Native) { + const nativePlugin = plugin as NativePlugin; - plugins.push(...secondaryPluginConfigs); - } + return formatNativePlugin(nativePlugin); + } + }); return { app_alias: deployment.jobAlias, @@ -420,7 +448,7 @@ export const formatNativeJobPayload = ( pipeline_input_type: deployment.pipelineInputType, pipeline_input_uri: deployment.pipelineInputUri || null, pipeline_params: !_.isEmpty(pipelineParams) ? pipelineParams : {}, - chainstore_response: false, + chainstore_response: true, // Enforced to true }; }; @@ -430,7 +458,7 @@ export const formatServiceJobPayload = ( deployment: ServiceJobDeployment, ) => { const jobTags = formatJobTags(specifications); - const containerResources = formatContainerResources(containerType, undefined); + const containerResources = formatContainerResources(containerType, []); const targetNodes = formatNodes(deployment.targetNodes); const spareNodes = formatNodes(deployment.spareNodes); @@ -452,12 +480,11 @@ export const formatServiceJobPayload = ( plugin_signature: 'CONTAINER_APP_RUNNER', IMAGE: containerType.image, CONTAINER_RESOURCES: containerResources, - PORT: containerType.port, + PORT: formatPort(containerType.port), TUNNEL_ENGINE: 'ngrok', - NGROK_AUTH_TOKEN: deployment.tunnelingToken || null, - NGROK_EDGE_LABEL: deployment.tunnelingLabel || null, + NGROK_AUTH_TOKEN: deployment.tunnelingToken ?? null, + NGROK_EDGE_LABEL: deployment.tunnelingLabel ?? null, TUNNEL_ENGINE_ENABLED: deployment.enableTunneling === 'True', - NGROK_USE_API: true, ENV: envVars, RESTART_POLICY: 'always', IMAGE_PULL_POLICY: 'always', @@ -576,3 +603,7 @@ export const onDotEnvPaste = async ( console.error('Failed to read clipboard:', error); } }; + +export const isGenericPlugin = (pluginSignature: string) => { + return pluginSignature === 'CONTAINER_APP_RUNNER' || pluginSignature === 'WORKER_APP_RUNNER'; +}; diff --git a/src/lib/utils.tsx b/src/lib/utils.tsx index 7668cca7..17902373 100644 --- a/src/lib/utils.tsx +++ b/src/lib/utils.tsx @@ -141,3 +141,44 @@ export function parseIfJson(input: string): T | string { return input; } } + +export function resizeImage(file: File, maxWidth = 512, maxHeight = 512, quality = 0.9): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + + // Maintain aspect ratio + if (width > height) { + if (width > maxWidth) { + height *= maxWidth / width; + width = maxWidth; + } + } else { + if (height > maxHeight) { + width *= maxHeight / height; + height = maxHeight; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('No canvas context')); + + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (!blob) return reject(new Error('Canvas toBlob failed')); + resolve(blob); + }, + 'image/jpeg', + quality, // Between 0 and 1 + ); + }; + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); +} diff --git a/src/pages/Account.tsx b/src/pages/Account.tsx index bd8ba7bd..c3afa6bd 100644 --- a/src/pages/Account.tsx +++ b/src/pages/Account.tsx @@ -1,21 +1,22 @@ import BurnReport from '@components/account/BurnReport'; -import Invoicing from '@components/account/Invoicing'; -import Overview from '@components/account/Overview'; +import Invoicing from '@components/account/invoicing/Invoicing'; +import Profile from '@components/account/profile/Profile'; import { routePath } from '@lib/routes/route-paths'; import CustomTabs from '@shared/CustomTabs'; import { useEffect, useState } from 'react'; -import { RiApps2Line, RiBillLine, RiFireLine } from 'react-icons/ri'; +import { CgProfile } from 'react-icons/cg'; +import { RiBillLine, RiFireLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; function Account() { - const [selectedTab, setSelectedTab] = useState<'overview' | 'invoicing' | 'burn-report'>('overview'); + const [selectedTab, setSelectedTab] = useState<'invoicing' | 'profile' | 'burn-report'>('invoicing'); const navigate = useNavigate(); useEffect(() => { const params = new URLSearchParams(window.location.search); const tab = params.get('tab'); - if (tab && (tab === 'overview' || tab === 'invoicing' || tab === 'burn-report')) { + if (tab && (tab === 'invoicing' || tab === 'profile' || tab === 'burn-report')) { setSelectedTab(tab); } }, [window.location.search]); @@ -24,16 +25,16 @@ function Account() {
, - }, { key: 'invoicing', title: 'Invoicing', icon: , }, + { + key: 'profile', + title: 'Profile', + icon: , + }, { key: 'burn-report', title: 'Burn Report', @@ -50,8 +51,8 @@ function Account() { }} /> - {selectedTab === 'overview' && } {selectedTab === 'invoicing' && } + {selectedTab === 'profile' && } {selectedTab === 'burn-report' && }
); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 6f3a442a..28f19ea0 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -4,12 +4,10 @@ import { ReaderContractAbi } from '@blockchain/ReaderContract'; import LoginCard from '@components/auth/LoginCard'; import RestrictedAccess from '@components/auth/RestrictedAccess'; import { Spinner } from '@heroui/spinner'; -import { getMultiNodeEpochsRange } from '@lib/api/oracles'; -import { config, environment, getCurrentEpoch, getDevAddress, isUsingDevAddress } from '@lib/config'; +import { config, environment, getDevAddress, isUsingDevAddress } from '@lib/config'; import { AuthenticationContextType, useAuthenticationContext } from '@lib/contexts/authentication'; import { BlockchainContextType, useBlockchainContext } from '@lib/contexts/blockchain'; import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; -import { isZeroAddress } from '@lib/utils'; import { EthAddress } from '@typedefs/blockchain'; import { ConnectKitButton, useModal } from 'connectkit'; import { useEffect, useState } from 'react'; @@ -121,7 +119,7 @@ function Login() { args: [address], }); - console.log(`User ${hasOracleNode ? '' : 'DOES NOT '}own an oracle`); + console.log('User owns oracle', hasOracleNode); return hasOracleNode; }; diff --git a/src/pages/deeploys/Project.tsx b/src/pages/deeploys/Project.tsx index e8566000..441ace1b 100644 --- a/src/pages/deeploys/Project.tsx +++ b/src/pages/deeploys/Project.tsx @@ -76,8 +76,6 @@ export default function Project() { const jobs: RunningJobWithDetails[] = await fetchRunningJobsWithDetails(appsOverride); const projectJobs = jobs.filter((job) => job.projectHash === projectHash); - console.log('[Project] Project jobs', projectJobs); - setRunningJobsWithDetails(projectJobs); } catch (error) { toast.error('Failed to fetch running jobs.'); diff --git a/src/pages/deeploys/drafts/EditJobDraft.tsx b/src/pages/deeploys/drafts/EditJobDraft.tsx index f914b103..ff4a0578 100644 --- a/src/pages/deeploys/drafts/EditJobDraft.tsx +++ b/src/pages/deeploys/drafts/EditJobDraft.tsx @@ -5,7 +5,7 @@ import db from '@lib/storage/db'; import { jobSchema } from '@schemas/index'; import ActionButton from '@shared/ActionButton'; import SupportFooter from '@shared/SupportFooter'; -import { DraftJob } from '@typedefs/deeploys'; +import { DraftJob, JobType } from '@typedefs/deeploys'; import { useLiveQuery } from 'dexie-react-hooks'; import { useEffect } from 'react'; import toast from 'react-hot-toast'; @@ -35,8 +35,6 @@ export default function EditJobDraft() { return; } - console.log(data); - try { const updatedJob = { ...draftJob, @@ -48,6 +46,12 @@ export default function EditJobDraft() { }, }; + if (data.jobType === JobType.Native) { + updatedJob.deployment.plugins = data.plugins; + } + + console.log('[EditJobDraft] onSubmit', updatedJob); + const jobId = await db.jobs.update(draftJob.id, updatedJob as DraftJob); console.log('[EditJobDraft] Job draft updated successfully', jobId); diff --git a/src/pages/deeploys/job/EditJob.tsx b/src/pages/deeploys/job/EditJob.tsx index 471262ac..0b31000b 100644 --- a/src/pages/deeploys/job/EditJob.tsx +++ b/src/pages/deeploys/job/EditJob.tsx @@ -32,7 +32,7 @@ import { ServiceJobDeployment, ServiceJobSpecifications, } from '@typedefs/deeploys'; -import { JobTypeOption, jobTypeOptions } from '@typedefs/jobType'; +import { JOB_TYPE_OPTIONS, JobTypeOption } from '@typedefs/jobType'; import { useEffect, useRef, useState } from 'react'; import { toast } from 'react-hot-toast'; import { RiArrowLeftLine } from 'react-icons/ri'; @@ -77,7 +77,7 @@ export default function EditJob() { useEffect(() => { if (job) { - setJobTypeOption(jobTypeOptions.find((option) => option.jobType === job.resources.jobType)); + setJobTypeOption(JOB_TYPE_OPTIONS.find((option) => option.jobType === job.resources.jobType)); } }, [job]); @@ -87,6 +87,8 @@ export default function EditJob() { return; } + console.log('[EditJob] onSubmit', data); + const additionalNodesRequested: number = data.specifications.targetNodesCount - Number(job.numberOfNodesRequested); const increasingTargetNodes: boolean = additionalNodesRequested > 0; @@ -135,7 +137,7 @@ export default function EditJob() { payload = formatNativeJobPayload( job!.resources.containerOrWorkerType, data.specifications as NativeJobSpecifications, - data.deployment as NativeJobDeployment, + { ...data.deployment, plugins: data.plugins } as NativeJobDeployment, ); break; @@ -180,8 +182,10 @@ export default function EditJob() { } if ( - updatePipelineResponse.status === 'success' && - (increasingTargetNodes ? scaleUpWorkersResponse.status === 'success' : true) + (updatePipelineResponse.status === 'success' || updatePipelineResponse.status === 'command_delivered') && + (increasingTargetNodes + ? scaleUpWorkersResponse.status === 'success' || scaleUpWorkersResponse.status === 'command_delivered' + : true) ) { deeployFlowModalRef.current?.progress('done'); setFetchAppsRequired(true); @@ -225,8 +229,12 @@ export default function EditJob() { text = 'Request timed out'; } else if (response.error) { text = response.error; - } else if (response.status && response.status !== 'success') { - text = `Request failed with status ${response.status}`; + } else if ( + response.status && + response.status !== 'success' && + response.status !== 'command_delivered' + ) { + text = `Request failed with status: ${response.status}`; } if (!text) { @@ -280,7 +288,7 @@ export default function EditJob() { target_nodes: targetNodes, target_nodes_count: 0, app_params: { - CONTAINER_RESOURCES: formatContainerResources(containerType, undefined), + CONTAINER_RESOURCES: formatContainerResources(containerType, []), }, project_id: job.projectHash, chainstore_response: true, diff --git a/src/pages/deeploys/job/ExtendJob.tsx b/src/pages/deeploys/job/ExtendJob.tsx index 390eef26..5388f1ac 100644 --- a/src/pages/deeploys/job/ExtendJob.tsx +++ b/src/pages/deeploys/job/ExtendJob.tsx @@ -4,7 +4,7 @@ import ExtendJobPageLoading from '@components/loading/ExtendJobPageLoading'; import ActionButton from '@shared/ActionButton'; import SupportFooter from '@shared/SupportFooter'; import { RunningJobWithResources } from '@typedefs/deeploys'; -import { JobTypeOption, jobTypeOptions } from '@typedefs/jobType'; +import { JOB_TYPE_OPTIONS, JobTypeOption } from '@typedefs/jobType'; import { useEffect, useState } from 'react'; import { RiArrowLeftLine } from 'react-icons/ri'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -18,7 +18,7 @@ export default function ExtendJob() { useEffect(() => { if (job) { - setJobTypeOption(jobTypeOptions.find((option) => option.jobType === job.resources.jobType)); + setJobTypeOption(JOB_TYPE_OPTIONS.find((option) => option.jobType === job.resources.jobType)); } }, [job]); diff --git a/src/pages/deeploys/job/Job.tsx b/src/pages/deeploys/job/Job.tsx index c458b9fc..f9ceb03d 100644 --- a/src/pages/deeploys/job/Job.tsx +++ b/src/pages/deeploys/job/Job.tsx @@ -1,5 +1,6 @@ import { CspEscrowAbi } from '@blockchain/CspEscrow'; -import JobConfigurations from '@components/job/config/JobConfigurations'; +import JobDeploymentSection from '@components/job/config/JobDeploymentSection'; +import JobPluginsSection from '@components/job/config/JobPluginsSection'; import JobBreadcrumbs from '@components/job/JobBreadcrumbs'; import JobFullUsage from '@components/job/JobFullUsage'; import JobInstances from '@components/job/JobInstances'; @@ -15,7 +16,7 @@ import RefreshRequiredAlert from '@shared/jobs/RefreshRequiredAlert'; import SupportFooter from '@shared/SupportFooter'; import { Apps } from '@typedefs/deeployApi'; import { RunningJob, RunningJobWithDetails, RunningJobWithResources } from '@typedefs/deeploys'; -import { JobTypeOption, jobTypeOptions } from '@typedefs/jobType'; +import { JOB_TYPE_OPTIONS, JobTypeOption } from '@typedefs/jobType'; import { uniq } from 'lodash'; import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; @@ -40,10 +41,10 @@ export default function Job() { // The aliases of the servers which responded to the successful job update const serverAliases: string[] | undefined = (location.state as { serverAliases?: string[] })?.serverAliases; - const [updatingServerAliases, setUpdatingServerAliases] = useState([]); + const [updatingServerAliases, setUpdatingServerAliases] = useState(); useEffect(() => { - if (serverAliases) { + if (serverAliases && updatingServerAliases === undefined) { setUpdatingServerAliases(serverAliases); } }, [serverAliases]); @@ -56,7 +57,7 @@ export default function Job() { useEffect(() => { if (job) { - setJobTypeOption(jobTypeOptions.find((option) => option.jobType === job.resources.jobType)); + setJobTypeOption(JOB_TYPE_OPTIONS.find((option) => option.jobType === job.resources.jobType)); } }, [job]); @@ -138,7 +139,7 @@ export default function Job() {
- {!!updatingServerAliases.length && ( + {!!updatingServerAliases && updatingServerAliases.length > 0 && (
- {/* Configuration */} - + {/* Deployment */} + + + {/* Plugins */} + {/* Instances */} +
External Domains
@@ -433,7 +433,7 @@ export default function TunnelPage() { +
Aliases
diff --git a/src/schemas/common.ts b/src/schemas/common.ts index 5a06c0fe..2ddecb4d 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -8,7 +8,43 @@ export const keyValueEntrySchema = z .object({ key: z.string().optional(), value: z.string().optional(), - valueType: z.enum(['text', 'json']).optional().default('text'), + }) + .refine( + (data) => { + if (!data.key && !data.value) { + return true; // Both empty = valid (empty row, will be ignored) + } + if (!data.key) { + return false; // Key missing + } + return true; // Key present + }, + { + message: 'Key is required', + path: ['key'], + }, + ) + .refine( + (data) => { + if (!data.key && !data.value) { + return true; // Both empty = valid (empty row, will be ignored) + } + if (!data.value) { + return false; // Value missing + } + return true; // Value present + }, + { + message: 'Value is required', + path: ['value'], + }, + ); + +export const customParameterEntrySchema = z + .object({ + key: z.string().optional(), + value: z.string().optional(), + valueType: z.enum(['string', 'json']).optional().default('string'), }) .refine( (data) => { @@ -80,6 +116,26 @@ export const getKeyValueEntriesArraySchema = (maxEntries?: number) => { ); }; +export const getCustomParametersArraySchema = (maxEntries: number = 50) => { + let schema = z.array(customParameterEntrySchema); + + if (maxEntries) { + schema = schema.max(maxEntries, `Maximum ${maxEntries} entries allowed`); + } + + return schema.refine( + (entries) => { + const keys = entries.map((entry) => entry.key?.trim()).filter((key) => key && key !== ''); // Only non-empty keys + + const uniqueKeys = new Set(keys); + return uniqueKeys.size === keys.length; + }, + { + message: 'Duplicate keys are not allowed', + }, + ); +}; + export const nodeSchema = z.object({ address: z .string() @@ -119,55 +175,6 @@ export const dynamicEnvEntrySchema = z message: 'Key is required when values are provided', path: ['key'], }, - ) - .refine( - (data) => { - // If key is empty, don't validate values - if (!data.key) { - return true; - } - // Check if any value is missing when key is present (skip host_ip fields) - const hasEmptyValue = data.values.some((pair) => pair.type !== 'host_ip' && !pair.value); - return !hasEmptyValue; - }, - { - message: 'All values are required when key is provided', - path: ['values'], - }, - ) - // Individual value refinements for specific error paths - .refine( - (data) => { - if (!data.key) return true; - if (data.values[0]?.type === 'host_ip') return true; - return data.values[0]?.value || false; - }, - { - message: 'Value is required', - path: ['values', 0, 'value'], - }, - ) - .refine( - (data) => { - if (!data.key) return true; - if (data.values[1]?.type === 'host_ip') return true; - return data.values[1]?.value || false; - }, - { - message: 'Value is required', - path: ['values', 1, 'value'], - }, - ) - .refine( - (data) => { - if (!data.key) return true; - if (data.values[2]?.type === 'host_ip') return true; - return data.values[2]?.value || false; - }, - { - message: 'Value is required', - path: ['values', 2, 'value'], - }, ); export const getStringSchema = (minLength: number, maxLength: number) => { @@ -201,6 +208,20 @@ export const getOptionalStringSchema = (maxLength: number) => { .optional(); }; +export const getOptionalStringWithSpacesSchema = (minLength: number, maxLength: number) => { + return z.union([ + z.literal(''), + z + .string() + .min(minLength, `Value must be at least ${minLength} characters`) + .max(maxLength, `Value cannot exceed ${maxLength} characters`) + .regex( + /^[a-zA-Z0-9\s!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]*$/, + 'Only letters, numbers, spaces and special characters allowed', + ), + ]); +}; + export const getNameWithoutSpacesSchema = (minLength: number, maxLength: number) => { return z .string({ required_error: 'Value is required' }) diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 5b9b0ab2..0f34cd42 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,7 +1,12 @@ import { z } from 'zod'; import { JobType } from '../typedefs/deeploys'; import { costAndDurationSchema } from './steps/costAndDuration'; -import { genericAppDeploymentSchema, nativeAppDeploymentSchema, serviceAppDeploymentSchema } from './steps/deployment'; +import { + genericAppDeploymentSchema, + nativeAppDeploymentSchema, + nativeAppPluginsSchema, + serviceAppDeploymentSchema, +} from './steps/deployment'; import { genericSpecificationsSchema, nativeSpecificationsSchema, serviceSpecificationsSchema } from './steps/specifications'; export const TARGET_NODES_REQUIRED_ERROR = 'All target nodes must be specified'; @@ -22,6 +27,7 @@ export const jobSchema = z jobType: z.literal(JobType.Native), specifications: nativeSpecificationsSchema, deployment: nativeAppDeploymentSchema, + plugins: nativeAppPluginsSchema, }), jobBaseSchema.extend({ jobType: z.literal(JobType.Service), diff --git a/src/schemas/profile.ts b/src/schemas/profile.ts new file mode 100644 index 00000000..7a062120 --- /dev/null +++ b/src/schemas/profile.ts @@ -0,0 +1,16 @@ +import z from 'zod'; +import { getOptionalStringWithSpacesSchema } from './common'; + +export const buildPublicProfileSchema = (brandingPlatforms: string[]) => { + const shape: Record = {}; + + for (const platform of brandingPlatforms) { + shape[platform] = z.union([z.literal(''), z.string().url({ message: 'Must be a valid https URL' })]); + } + + return z.object({ + name: getOptionalStringWithSpacesSchema(3, 32), + description: getOptionalStringWithSpacesSchema(0, 80), + links: z.object(shape), + }); +}; diff --git a/src/schemas/steps/deployment.ts b/src/schemas/steps/deployment.ts index 495e922a..38a31928 100644 --- a/src/schemas/steps/deployment.ts +++ b/src/schemas/steps/deployment.ts @@ -6,6 +6,7 @@ import { POLICY_TYPES } from '@data/policyTypes'; import { dynamicEnvEntrySchema, enabledBooleanTypeValue, + getCustomParametersArraySchema, getFileVolumesArraySchema, getKeyValueEntriesArraySchema, getNameWithoutSpacesSchema, @@ -14,7 +15,7 @@ import { nodeSchema, workerCommandSchema, } from '@schemas/common'; -import { SecondaryPluginType } from '@typedefs/steps/deploymentStepTypes'; +import { BasePluginType, PluginType } from '@typedefs/steps/deploymentStepTypes'; import { z } from 'zod'; // Common validation patterns @@ -40,10 +41,15 @@ const validations = { .regex(/^https?:\/\/.+/, 'Must be a valid URI'), optionalUri: z - .string() - .refine((val) => val === '' || val.length >= 2, 'Value must be at least 2 characters') - .refine((val) => val === '' || val.length <= 256, 'Value cannot exceed 256 characters') - .refine((val) => val === '' || /^https?:\/\/.+/.test(val), 'Must be a valid URI'), + .union([ + z.literal(''), + z + .string() + .min(2, 'Value must be at least 2 characters') + .max(256, 'Value cannot exceed 256 characters') + .regex(/^https?:\/\/.+/, 'Must be a valid URI'), + ]) + .optional(), port: z.union([ z.literal(''), @@ -54,7 +60,33 @@ const validations = { .max(65535, 'Value cannot exceed 65535'), ]), - envVars: getKeyValueEntriesArraySchema(), + ports: z + .array( + z.object({ + hostPort: z + .number() + .int('Value must be a whole number') + .min(1, 'Value must be at least 1') + .max(65535, 'Value cannot exceed 65535'), + containerPort: z + .number() + .int('Value must be a whole number') + .min(1, 'Value must be at least 1') + .max(65535, 'Value cannot exceed 65535'), + }), + ) + .refine( + (entries) => { + const hostPorts = entries.map((entry) => entry.hostPort); + return hostPorts.length === new Set(hostPorts).size; + }, + { + message: 'Duplicate host ports are not allowed', + }, + ) + .default([]), + + envVars: getKeyValueEntriesArraySchema(50), dynamicEnvVars: z .array(dynamicEnvEntrySchema) .max(50, 'Maximum 50 dynamic environment variables') @@ -69,9 +101,9 @@ const validations = { message: 'Duplicate keys are not allowed', }, ), - customParams: getKeyValueEntriesArraySchema(50), + customParams: getCustomParametersArraySchema(), pipelineParams: getKeyValueEntriesArraySchema(50), - volumes: getKeyValueEntriesArraySchema(), + volumes: getKeyValueEntriesArraySchema(50), fileVolumes: getFileVolumesArraySchema(50), // Enum patterns @@ -132,7 +164,7 @@ export const applyDeploymentTypeRefinements = (schema) => { } // Validate that crUsername and crPassword are provided when crVisibility is 'Private' - if (data.deploymentType.type === 'container' && data.deploymentType.crVisibility === 'Private') { + if (data.deploymentType.pluginType === PluginType.Container && data.deploymentType.crVisibility === 'Private') { if (!data.deploymentType.crUsername) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -150,7 +182,7 @@ export const applyDeploymentTypeRefinements = (schema) => { } // Validate that username and accessToken are provided when worker repositoryVisibility is 'private' - if (data.deploymentType.type === 'worker' && data.deploymentType.repositoryVisibility === 'private') { + if (data.deploymentType.pluginType === PluginType.Worker && data.deploymentType.repositoryVisibility === 'private') { if (!data.deploymentType.username) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -184,7 +216,10 @@ export const applyCustomPluginSignatureRefinements = (schema) => { ); }; -const baseDeploymentSchema = z.object({ +const mainDeploymentSchema = z.object({ + jobAlias: validations.jobAlias, + + // Target Nodes autoAssign: z.boolean(), targetNodes: z.array(nodeSchema).refine( (nodes) => { @@ -209,23 +244,40 @@ const baseDeploymentSchema = z.object({ }, ), allowReplicationInTheWild: z.boolean(), +}); + +const tunnelingSchema = z.object({ enableTunneling: z.enum(BOOLEAN_TYPES, { required_error: 'Value is required' }), - tunnelingLabel: getOptionalStringSchema(64), + port: validations.port, tunnelingToken: getOptionalStringSchema(512), + tunnelingLabel: z + .union([ + z.literal(''), + z + .string() + .min(3, 'Value must be at least 3 characters') + .max(64, 'Value cannot exceed 64 characters') + .regex( + /^[a-zA-Z0-9!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]*$/, + 'Only letters, numbers and special characters allowed', + ), + ]) + .optional(), }); +const baseDeploymentSchema = mainDeploymentSchema.merge(tunnelingSchema); + const containerDeploymentTypeSchema = z.object({ - type: z.literal('container'), + pluginType: z.literal(PluginType.Container), containerImage: validations.containerImage, containerRegistry: validations.containerRegistry, crVisibility: z.enum(CR_VISIBILITY_OPTIONS, { required_error: 'Value is required' }), crUsername: z.union([getStringSchema(3, 128), z.literal('')]).optional(), crPassword: z.union([getStringSchema(3, 256), z.literal('')]).optional(), - ports: z.record(z.string(), z.string()).optional(), }); const workerDeploymentTypeSchema = z.object({ - type: z.literal('worker'), + pluginType: z.literal(PluginType.Worker), image: getStringSchema(3, 256), repositoryUrl: z .string({ required_error: 'Value is required' }) @@ -254,15 +306,16 @@ const workerDeploymentTypeSchema = z.object({ message: 'Duplicate commands are not allowed', }, ), - ports: z.record(z.string(), z.string()).optional(), }); -export const deploymentTypeSchema = z.discriminatedUnion('type', [containerDeploymentTypeSchema, workerDeploymentTypeSchema]); +export const deploymentTypeSchema = z.discriminatedUnion('pluginType', [ + containerDeploymentTypeSchema, + workerDeploymentTypeSchema, +]); const genericAppDeploymentSchemaWihtoutRefinements = baseDeploymentSchema.extend({ - jobAlias: validations.jobAlias, deploymentType: deploymentTypeSchema, - port: validations.port, + ports: validations.ports, envVars: validations.envVars, dynamicEnvVars: validations.dynamicEnvVars, volumes: validations.volumes, @@ -275,15 +328,18 @@ export const genericAppDeploymentSchema = applyDeploymentTypeRefinements( applyTunnelingRefinements(genericAppDeploymentSchemaWihtoutRefinements), ); -// Secondary plugins -const baseGenericSecondaryPluginSchema = z.object({ - secondaryPluginType: z.literal(SecondaryPluginType.Generic), +// Plugins +const genericPluginSchema = z.object({ + basePluginType: z.literal(BasePluginType.Generic), // Tunneling port: validations.port, enableTunneling: z.enum(BOOLEAN_TYPES, { required_error: 'Value is required' }), tunnelingToken: getOptionalStringSchema(512), + // Ports + ports: validations.ports, + // Deployment type deploymentType: deploymentTypeSchema, @@ -298,10 +354,8 @@ const baseGenericSecondaryPluginSchema = z.object({ imagePullPolicy: z.enum(POLICY_TYPES, { required_error: 'Value is required' }), }); -const genericSecondaryPluginSchema = baseGenericSecondaryPluginSchema; - -const baseNativeSecondaryPluginSchema = z.object({ - secondaryPluginType: z.literal(SecondaryPluginType.Native), +const nativePluginSchema = z.object({ + basePluginType: z.literal(BasePluginType.Native), // Signature pluginSignature: validations.pluginSignature, @@ -316,26 +370,22 @@ const baseNativeSecondaryPluginSchema = z.object({ customParams: validations.customParams, }); -const secondaryPluginSchemaWithoutRefinements = z.discriminatedUnion('secondaryPluginType', [ - genericSecondaryPluginSchema, - baseNativeSecondaryPluginSchema, -]); +const pluginSchemaWithoutRefinements = z.discriminatedUnion('basePluginType', [genericPluginSchema, nativePluginSchema]); -const secondaryPluginSchema = applyCustomPluginSignatureRefinements( - applyDeploymentTypeRefinements(applyTunnelingRefinements(secondaryPluginSchemaWithoutRefinements)), +const pluginSchema = applyCustomPluginSignatureRefinements( + applyDeploymentTypeRefinements(applyTunnelingRefinements(pluginSchemaWithoutRefinements)), ); -const nativeAppDeploymentSchemaWihtoutRefinements = baseDeploymentSchema.extend({ - jobAlias: validations.jobAlias, - pluginSignature: validations.pluginSignature, - customPluginSignature: getOptionalStringSchema(128), - port: validations.port, - customParams: validations.customParams, +export const nativeAppPluginsSchema = z + .array(pluginSchema) + .min(1, 'At least one plugin is required') + .max(5, 'Only 5 plugins allowed'); + +const nativeAppDeploymentSchemaWihtoutRefinements = mainDeploymentSchema.extend({ pipelineParams: validations.pipelineParams, pipelineInputType: z.enum(PIPELINE_INPUT_TYPES, { required_error: 'Value is required' }), - pipelineInputUri: validations.optionalUri.optional(), + pipelineInputUri: validations.optionalUri, chainstoreResponse: validations.chainstoreResponse, - secondaryPlugins: z.array(secondaryPluginSchema).max(5, 'Only 5 secondary plugins allowed').optional(), }); export const nativeAppDeploymentSchema = applyCustomPluginSignatureRefinements( @@ -343,8 +393,6 @@ export const nativeAppDeploymentSchema = applyCustomPluginSignatureRefinements( ); const serviceAppDeploymentSchemaWihtoutRefinements = baseDeploymentSchema.extend({ - jobAlias: validations.jobAlias, - port: validations.port, enableTunneling: z.enum(BOOLEAN_TYPES, { required_error: 'Value is required' }), tunnelingToken: getOptionalStringSchema(512), inputs: validations.envVars, diff --git a/src/schemas/steps/specifications.ts b/src/schemas/steps/specifications.ts index 843e8e78..9c19d819 100644 --- a/src/schemas/steps/specifications.ts +++ b/src/schemas/steps/specifications.ts @@ -18,7 +18,7 @@ const getRequiredIntegerSchema = (max: number) => { const baseSpecificationsSchema = z.object({ gpuType: z.union([z.literal(''), z.enum(gpuTypes.map((type) => type.name) as [string, ...string[]])]).optional(), - applicationType: z.enum(APPLICATION_TYPES, { required_error: 'Application type is required' }), + applicationType: z.enum(APPLICATION_TYPES, { required_error: 'Application type is required' }).optional(), // Disabled for now targetNodesCount: getRequiredIntegerSchema(100), jobTags: z.array(z.string()), nodesCountries: z.array(z.string()), diff --git a/src/shared/InputWithLabel.tsx b/src/shared/InputWithLabel.tsx index 33f3a1be..1af9ef6e 100644 --- a/src/shared/InputWithLabel.tsx +++ b/src/shared/InputWithLabel.tsx @@ -1,18 +1,21 @@ import { InputProps } from '@heroui/input'; import StyledInput from '@shared/StyledInput'; +import { useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { RiClipboardLine } from 'react-icons/ri'; +import { RiCheckLine, RiClipboardLine, RiFileCopyLine } from 'react-icons/ri'; import Label from './Label'; +import SecretValueToggle from './jobs/SecretValueToggle'; interface Props extends InputProps { name: string; label: string; placeholder: string; isOptional?: boolean; - displayPasteIcon?: boolean; onBlur?: () => void; onPasteValue?: (value: string) => void; customLabel?: React.ReactNode; + endContent?: 'copy' | 'paste'; + hasSecretValue?: boolean; } export default function InputWithLabel({ @@ -20,74 +23,111 @@ export default function InputWithLabel({ label, placeholder, isOptional, - displayPasteIcon, customLabel, + endContent, + hasSecretValue, ...props }: Props) { const { control } = useFormContext(); + const [copied, setCopied] = useState(false); + const [isFieldSecret, setFieldSecret] = useState(!!hasSecretValue); + return (
{customLabel ? customLabel :
); } diff --git a/src/shared/JsonEditor.tsx b/src/shared/JsonEditor.tsx new file mode 100644 index 00000000..1d9afa23 --- /dev/null +++ b/src/shared/JsonEditor.tsx @@ -0,0 +1,70 @@ +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { json, jsonParseLinter } from '@codemirror/lang-json'; +import { linter } from '@codemirror/lint'; +import { keymap } from '@codemirror/view'; +import CodeMirror from '@uiw/react-codemirror'; +import { useCallback, useState } from 'react'; + +export default function JsonEditor({ + initialValue = '{}', + height = '300px', + onChange, + onBlur, + errorMessage, +}: { + initialValue?: string; + height?: string; + onChange: (value: string) => void; + onBlur: () => void; + errorMessage?: string; +}) { + const [code, setCode] = useState(initialValue); + + const handleChange = useCallback( + (value: string) => { + setCode(value); + onChange(value); + }, + [onChange], + ); + + const handleBlur = useCallback(() => { + const trimmed = code.trim(); + + if (!trimmed) { + onBlur(); + return; + } + + try { + const parsed = JSON.parse(trimmed); + const formatted = JSON.stringify(parsed, null, 2); + + if (formatted !== code) { + setCode(formatted); + } + } catch { + onBlur(); + return; + } + + onBlur(); + }, [code, onBlur]); + + return ( +
+ + + {!!errorMessage && ( +
{errorMessage}
+ )} +
+ ); +} diff --git a/src/shared/PortMappingSection.tsx b/src/shared/PortMappingSection.tsx index 64be79ad..82a53cac 100644 --- a/src/shared/PortMappingSection.tsx +++ b/src/shared/PortMappingSection.tsx @@ -1,121 +1,142 @@ -import ConfigSectionTitle from '@components/job/config/ConfigSectionTitle'; -import StyledInput from '@shared/StyledInput'; -import VariableSectionRemove from '@shared/jobs/VariableSectionRemove'; -import { useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { RiAddLine } from 'react-icons/ri'; -import DeeployWarning from './jobs/DeeployWarning'; - -interface PortMappingSectionProps { - name: string; - label?: string; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import DeeployWarningAlert from './jobs/DeeployWarningAlert'; +import VariableSectionControls from './jobs/VariableSectionControls'; +import VariableSectionIndex from './jobs/VariableSectionIndex'; +import VariableSectionRemove from './jobs/VariableSectionRemove'; +import StyledInput from './StyledInput'; + +interface PortMappingEntryWithId { + id: string; + hostPort: number; + containerPort: number; } -export default function PortMappingSection({ name, label = 'Port Mapping' }: PortMappingSectionProps) { - const { setValue, watch, formState, trigger } = useFormContext(); - const ports = watch(name) || {}; - const [portEntries, setPortEntries] = useState>( - Object.entries(ports).map(([hostPort, containerPort], index) => ({ - id: `port-${index}`, - hostPort, - containerPort: String(containerPort), - })), - ); +export default function PortMappingSection({ baseName = 'deployment' }: { baseName?: string }) { + const name = `${baseName}.ports`; + + const { trigger, control, formState } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name, + }); + + // Explicitly type the fields to match the expected structure + const entries = fields as PortMappingEntryWithId[]; - const updatePorts = (entries: Array<{ id: string; hostPort: string; containerPort: string }>) => { - const newPorts: Record = {}; - entries.forEach((entry) => { - if (entry.hostPort && entry.containerPort) { - newPorts[entry.hostPort] = entry.containerPort; - } - }); - setValue(name, newPorts); - trigger(name); - }; - - const addPortMapping = () => { - const newEntry = { - id: `port-${Date.now()}`, - hostPort: '', - containerPort: '', - }; - const newEntries = [...portEntries, newEntry]; - setPortEntries(newEntries); - }; - - const removePortMapping = (id: string) => { - const newEntries = portEntries.filter((entry) => entry.id !== id); - setPortEntries(newEntries); - updatePorts(newEntries); - }; - - const updateEntry = (id: string, field: 'hostPort' | 'containerPort', value: string) => { - const newEntries = portEntries.map((entry) => (entry.id === id ? { ...entry, [field]: value } : entry)); - setPortEntries(newEntries); - updatePorts(newEntries); - }; - - // Check for duplicate host ports - const hostPorts = portEntries.map((entry) => entry.hostPort).filter((port) => port); - const duplicateHostPorts = hostPorts.filter((port, index) => hostPorts.indexOf(port) !== index); - const hasDuplicateHostPorts = duplicateHostPorts.length > 0; + // Get array-level errors + const errors = name.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined; + } + + return (acc as Record)[segment]; + }, formState.errors as unknown) as any; return (
- - - Port Availability
} - description={ -
- The plugin may fail to start if the specified host ports are not available on your target nodes. Ensure - the ports you map are free and accessible. -
- } - /> + {entries.length > 0 && + entries.map((entry: PortMappingEntryWithId, index) => { + // Get the error for this specific entry + const entryError = errors?.[index]; - {portEntries.length === 0 &&
No port mappings added yet.
} - - {portEntries.map((entry, index) => ( -
-
- updateEntry(entry.id, 'hostPort', e.target.value)} - isInvalid={hasDuplicateHostPorts && duplicateHostPorts.includes(entry.hostPort)} - errorMessage={ - hasDuplicateHostPorts && duplicateHostPorts.includes(entry.hostPort) - ? 'Duplicate host port' - : undefined - } - /> -
-
- updateEntry(entry.id, 'containerPort', e.target.value)} - /> -
- removePortMapping(entry.id)} /> -
- ))} - - {portEntries.length > 0 && ( -
- Format: host_port:container_port -
- )} + return ( +
+ - {hasDuplicateHostPorts && ( -
⚠️ Duplicate host ports detected. Each host port must be unique.
- )} +
+ { + // Check for specific error on this key input or array-level error + const specificKeyError = entryError?.key; + const hasError = !!fieldState.error || !!specificKeyError || !!errors?.root?.message; - {/* TODO: Use append */} -
- Add -
+ return ( + { + const value = e.target.value; + field.onChange(value === '' ? undefined : Number(value)); + }} + onBlur={async () => { + field.onBlur(); + + // Trigger validation for the entire array to check for duplicate keys + if (entries.length > 1) { + await trigger(name); + } + }} + isInvalid={hasError} + errorMessage={ + fieldState.error?.message || + specificKeyError?.message || + (errors?.root?.message && index === 0 ? errors.root.message : undefined) + } + /> + ); + }} + /> + + { + // Check for specific error on this value input + const specificValueError = entryError?.value; + const hasError = !!fieldState.error || !!specificValueError; + + return ( + { + const value = e.target.value; + field.onChange(value === '' ? undefined : Number(value)); + }} + onBlur={async () => { + field.onBlur(); + }} + isInvalid={hasError} + errorMessage={fieldState.error?.message || specificValueError?.message} + /> + ); + }} + /> +
+ + { + remove(index); + }} + /> +
+ ); + })} + + append({ hostPort: undefined, containerPort: undefined })} + fieldsLength={entries.length} + maxFields={50} + remove={remove} + /> + + {entries.length > 0 && ( + Port Availability
} + description={ +
+ The plugin may fail to start if the specified host ports are not available on your target nodes. + Ensure the ports you map are free and accessible. +
+ } + /> + )}
); } diff --git a/src/shared/ProfileRow.tsx b/src/shared/ProfileRow.tsx new file mode 100644 index 00000000..d7e59250 --- /dev/null +++ b/src/shared/ProfileRow.tsx @@ -0,0 +1,11 @@ +export default function ProfileRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+ +
+
{value}
+
+
+ ); +} diff --git a/src/shared/SelectWithLabel.tsx b/src/shared/SelectWithLabel.tsx index a489ee37..156e8289 100644 --- a/src/shared/SelectWithLabel.tsx +++ b/src/shared/SelectWithLabel.tsx @@ -8,9 +8,10 @@ interface Props { label?: string; options: readonly string[]; onSelect?: (value: string) => void; + isDisabled?: boolean; } -export default function SelectWithLabel({ name, label, options, onSelect }: Props) { +export default function SelectWithLabel({ name, label, options, onSelect, isDisabled = false }: Props) { const { control } = useFormContext(); return ( @@ -32,6 +33,7 @@ export default function SelectWithLabel({ name, label, options, onSelect }: Prop isInvalid={!!fieldState.error} errorMessage={fieldState.error?.message} placeholder="Select an option" + isDisabled={isDisabled} > {options.map((option) => ( diff --git a/src/shared/SmallTag.tsx b/src/shared/SmallTag.tsx index 9682f0f8..b4c8f704 100644 --- a/src/shared/SmallTag.tsx +++ b/src/shared/SmallTag.tsx @@ -15,7 +15,8 @@ export const SmallTag: FunctionComponent< | 'accent' | 'darkred' | 'darkgreen' - | 'pink'; + | 'pink' + | 'soft'; isLarge?: boolean; }> > = ({ children, variant = 'default', isLarge = false }) => ( @@ -31,11 +32,12 @@ export const SmallTag: FunctionComponent< 'bg-orange-100 text-orange-600': variant === 'orange', 'bg-emerald-100 text-emerald-600': variant === 'emerald', 'bg-purple-100 text-purple-600': variant === 'purple', - 'bg-yellow-100 text-yellow-600': variant === 'yellow', + 'bg-orange-100 text-yellow-600': variant === 'yellow', 'bg-blue-100': variant === 'accent', 'bg-red-150 text-red-600': variant === 'darkred', 'bg-green-150 text-green-600': variant === 'darkgreen', 'bg-pink-100 text-pink-600': variant === 'pink', + 'bg-[#e7edf6] text-slate-500': variant === 'soft', })} > {children} diff --git a/src/shared/cards/DetailsCard.tsx b/src/shared/cards/DetailsCard.tsx new file mode 100644 index 00000000..bea37750 --- /dev/null +++ b/src/shared/cards/DetailsCard.tsx @@ -0,0 +1,3 @@ +export const DetailsCard = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; diff --git a/src/shared/cards/SlateCard.tsx b/src/shared/cards/SlateCard.tsx index 69e56233..a5039005 100644 --- a/src/shared/cards/SlateCard.tsx +++ b/src/shared/cards/SlateCard.tsx @@ -2,15 +2,17 @@ import { FunctionComponent, PropsWithChildren } from 'react'; interface Props { title?: string; + titleElement?: React.ReactNode; label?: React.ReactNode; } -export const SlateCard: FunctionComponent> = ({ children, title, label }) => { +export const SlateCard: FunctionComponent> = ({ children, title, titleElement, label }) => { return (
- {title && ( + {(!!title || !!titleElement || !!label) && (
-
{title}
+ {title &&
{title}
} + {titleElement && <>{titleElement}} {label && <>{label}}
)} diff --git a/src/shared/jobs/ContainerResourcesInfo.tsx b/src/shared/jobs/ContainerResourcesInfo.tsx index ee7d2b99..3ccf14c1 100644 --- a/src/shared/jobs/ContainerResourcesInfo.tsx +++ b/src/shared/jobs/ContainerResourcesInfo.tsx @@ -1,17 +1,15 @@ import { ContainerOrWorkerType, GpuType, gpuTypes } from '@data/containerResources'; import Label from '@shared/Label'; import { SmallTag } from '@shared/SmallTag'; -import { JobType } from '@typedefs/deeploys'; import { useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; interface Props { - jobType: JobType; name: string; options: ContainerOrWorkerType[]; } -export default function ContainerResourcesInfo({ jobType, name, options }: Props) { +export default function ContainerResourcesInfo({ name, options }: Props) { const { watch } = useFormContext(); const containerOrWorkerTypeName: string = watch(name); diff --git a/src/shared/jobs/DeeployErrorAlert.tsx b/src/shared/jobs/DeeployErrorAlert.tsx new file mode 100644 index 00000000..fabdda8b --- /dev/null +++ b/src/shared/jobs/DeeployErrorAlert.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import { RiErrorWarningLine } from 'react-icons/ri'; + +export default function DeeployErrorAlert({ title, description }: { title: ReactNode; description: ReactNode }) { + return ( +
+
+ + + {title} +
+ +
{description}
+
+ ); +} diff --git a/src/shared/jobs/DeeployInfoAlert.tsx b/src/shared/jobs/DeeployInfoAlert.tsx new file mode 100644 index 00000000..989997cf --- /dev/null +++ b/src/shared/jobs/DeeployInfoAlert.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import { RiInformationLine } from 'react-icons/ri'; + +export default function DeeployInfoAlert({ title, description }: { title: ReactNode; description: ReactNode }) { + return ( +
+
+ + + {title} +
+ +
{description}
+
+ ); +} diff --git a/src/shared/jobs/DeeployInfoTag.tsx b/src/shared/jobs/DeeployInfoTag.tsx new file mode 100644 index 00000000..ceb83c65 --- /dev/null +++ b/src/shared/jobs/DeeployInfoTag.tsx @@ -0,0 +1,17 @@ +import { SmallTag } from '@shared/SmallTag'; +import { ReactNode } from 'react'; +import { RiInformationLine } from 'react-icons/ri'; + +export default function DeeployInfoTag({ text }: { text: string | ReactNode }) { + return ( + +
+
+ +
+ +
{text}
+
+
+ ); +} diff --git a/src/shared/jobs/DeeployWarning.tsx b/src/shared/jobs/DeeployWarningAlert.tsx similarity index 77% rename from src/shared/jobs/DeeployWarning.tsx rename to src/shared/jobs/DeeployWarningAlert.tsx index 61b324cf..2c0c51dc 100644 --- a/src/shared/jobs/DeeployWarning.tsx +++ b/src/shared/jobs/DeeployWarningAlert.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react'; import { RiErrorWarningLine } from 'react-icons/ri'; -export default function DeeployWarning({ title, description }: { title: ReactNode; description: ReactNode }) { +export default function DeeployWarningAlert({ title, description }: { title: ReactNode; description: ReactNode }) { return (
diff --git a/src/shared/jobs/DynamicEnvSection.tsx b/src/shared/jobs/DynamicEnvSection.tsx index 0d0d3a6b..3cdc627f 100644 --- a/src/shared/jobs/DynamicEnvSection.tsx +++ b/src/shared/jobs/DynamicEnvSection.tsx @@ -3,173 +3,174 @@ import { SelectItem } from '@heroui/select'; import StyledInput from '@shared/StyledInput'; import StyledSelect from '@shared/StyledSelect'; import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; -import { RiAddLine } from 'react-icons/ri'; +import VariableSectionControls from './VariableSectionControls'; import VariableSectionIndex from './VariableSectionIndex'; import VariableSectionRemove from './VariableSectionRemove'; // This component assumes it's being used in the deployment step export default function DynamicEnvSection({ baseName = 'deployment' }: { baseName?: string }) { + const name = `${baseName}.dynamicEnvVars`; + const { control, formState, trigger } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, - name: `${baseName}.dynamicEnvVars`, + name, }); // Get array-level errors - const errors = (formState.errors[baseName] as any)?.dynamicEnvVars; + const errors = name.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined; + } + + return (acc as Record)[segment]; + }, formState.errors as unknown) as any; return (
-
- {fields.length === 0 ? ( -
No dynamic environment variables added yet.
- ) : ( - fields.map((field, index) => { - // Get the error for this specific entry - const entryError = errors?.[index]; - - return ( -
-
- - - { - // Check for specific error on this key input or array-level error - const specificKeyError = entryError?.key; - const hasError = - !!fieldState.error || !!specificKeyError || !!errors?.root?.message; - - return ( - { - const value = e.target.value; - field.onChange(value); + {fields.map((field, index) => { + // Get the error for this specific entry + const entryError = errors?.[index]; + + return ( +
+
+ + + { + // Check for specific error on this key input or array-level error + const specificKeyError = entryError?.key; + const hasError = !!fieldState.error || !!specificKeyError || !!errors?.root?.message; + + return ( + { + const value = e.target.value; + field.onChange(value); + }} + onBlur={async () => { + field.onBlur(); + + // Trigger validation for the entire array to check for duplicate keys + if (fields.length > 1) { + await trigger(`${baseName}.dynamicEnvVars`); + } + }} + isInvalid={hasError} + errorMessage={ + fieldState.error?.message || + specificKeyError?.message || + (errors?.root?.message && index === 0 ? errors.root.message : undefined) + } + /> + ); + }} + /> + + remove(index)} /> +
+ + {[0, 1, 2].map((k) => ( +
+
+
+ ( + { + const selectedKey = Array.from(keys)[0] as string; + field.onChange(selectedKey); + + // Trigger validation for the corresponding value input + await trigger(`${baseName}.dynamicEnvVars.${index}.values.${k}.value`); }} - onBlur={async () => { - field.onBlur(); + onBlur={field.onBlur} + isInvalid={!!fieldState.error} + errorMessage={fieldState.error?.message} + placeholder="Select an option" + > + {DYNAMIC_ENV_TYPES.map((envType) => ( + +
+
{envType}
+
+
+ ))} +
+ )} + /> +
- // Trigger validation for the entire array to check for duplicate keys - if (fields.length > 1) { - await trigger(`${baseName}.dynamicEnvVars`); - } - }} - isInvalid={hasError} - errorMessage={ - fieldState.error?.message || - specificKeyError?.message || - (errors?.root?.message && index === 0 ? errors.root.message : undefined) - } - /> - ); - }} - /> - - remove(index)} /> +
+ { + // Check for specific error on this value input + const specificValueError = entryError?.values?.[k]?.value; + + // Watch the type value to conditionally disable input + const typeValue = useWatch({ + control, + name: `${baseName}.dynamicEnvVars.${index}.values.${k}.type`, + }); + + const isHostIP = typeValue === 'host_ip'; + + return ( + { + const value = e.target.value; + field.onChange(value); + // Trigger validation for the entire entry when value changes + await trigger(`${baseName}.dynamicEnvVars.${index}`); + }} + onBlur={field.onBlur} + isInvalid={!!fieldState.error || !!specificValueError} + errorMessage={fieldState.error?.message || specificValueError?.message} + isDisabled={isHostIP} + /> + ); + }} + /> +
- {[0, 1, 2].map((k) => ( -
-
-
- ( - { - const selectedKey = Array.from(keys)[0] as string; - field.onChange(selectedKey); - }} - onBlur={field.onBlur} - isInvalid={!!fieldState.error} - errorMessage={fieldState.error?.message} - placeholder="Select an option" - > - {DYNAMIC_ENV_TYPES.map((envType) => ( - -
-
{envType}
-
-
- ))} -
- )} - /> -
- -
- { - // Check for specific error on this value input - const specificValueError = entryError?.values?.[k]?.value; - - // Watch the type value to conditionally disable input - const typeValue = useWatch({ - control, - name: `${baseName}.dynamicEnvVars.${index}.values.${k}.type`, - }); - - const isHostIP = typeValue === 'host_ip'; - - return ( - { - const value = e.target.value; - field.onChange(value); - // Trigger validation for the entire entry when value changes - await trigger(`${baseName}.dynamicEnvVars.${index}`); - }} - onBlur={field.onBlur} - isInvalid={!!fieldState.error || !!specificValueError} - errorMessage={ - fieldState.error?.message || specificValueError?.message - } - isDisabled={isHostIP} - /> - ); - }} - /> -
-
- - {/* Hidden, used only for styling */} -
Remove
-
- ))} + {/* Hidden, used only for styling */} +
Remove
- ); + ))} +
+ ); + })} + + + append({ + key: '', + values: [ + { type: DYNAMIC_ENV_TYPES[0], value: '' }, + { type: DYNAMIC_ENV_TYPES[0], value: '' }, + { type: DYNAMIC_ENV_TYPES[0], value: '' }, + ], }) - )} -
- - {fields.length < 50 && ( -
- append({ - key: '', - values: [ - { type: DYNAMIC_ENV_TYPES[0], value: '' }, - { type: DYNAMIC_ENV_TYPES[0], value: '' }, - { type: DYNAMIC_ENV_TYPES[0], value: '' }, - ], - }) - } - > - Add -
- )} + } + fieldsLength={fields.length} + maxFields={50} + remove={remove} + />
); } diff --git a/src/shared/jobs/EnvVariablesCard.tsx b/src/shared/jobs/EnvVariablesCard.tsx index 9fbb2c48..0f88f3d5 100644 --- a/src/shared/jobs/EnvVariablesCard.tsx +++ b/src/shared/jobs/EnvVariablesCard.tsx @@ -3,6 +3,7 @@ import { onDotEnvPaste } from '@lib/deeploy-utils'; import { SlateCard } from '@shared/cards/SlateCard'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { RiClipboardLine } from 'react-icons/ri'; +import DeeployInfoTag from './DeeployInfoTag'; import KeyValueEntriesSection from './KeyValueEntriesSection'; export default function EnvVariablesCard({ @@ -24,27 +25,26 @@ export default function EnvVariablesCard({ title="ENV Variables" label={ } >
-
- You can copy & paste the contents of your .env file using the button - above. -
+ ((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined; + } + + return (acc as Record)[segment]; + }, formState.errors as unknown) as any; return (
-
- {fields.length === 0 ? ( -
No file volumes added yet.
- ) : ( - fields.map((field, index) => { - // Get the error for this specific entry - const entryError = errors?.[index]; - - return ( -
-
- - -
-
- { - // Check for an error on this specific property - const specificError = entryError?.name; - const hasError = - !!fieldState.error || !!specificError || !!errors?.root?.message; - - return ( - { - const value = e.target.value; - field.onChange(value); - }} - onBlur={async () => { - field.onBlur(); - - // Trigger validation for the entire array to check for duplicate keys - if (fields.length > 1) { - await trigger(`${baseName}.fileVolumes`); - } - }} - isInvalid={hasError} - errorMessage={ - fieldState.error?.message || - specificError?.message || - (errors?.root?.message && index === 0 - ? errors.root.message - : undefined) - } - /> - ); - }} - /> -
- -
- { - // Check for an error on this specific property - const specificError = entryError?.mountingPoint; - const hasError = - !!fieldState.error || !!specificError || !!errors?.root?.message; - - return ( - { - const value = e.target.value; - field.onChange(value); - }} - onBlur={async () => { - field.onBlur(); - - // Trigger validation for the entire array to check for duplicate keys - if (fields.length > 1) { - await trigger(`${baseName}.fileVolumes`); - } - }} - isInvalid={hasError} - errorMessage={ - fieldState.error?.message || - specificError?.message || - (errors?.root?.message && index === 0 - ? errors.root.message - : undefined) - } - /> - ); - }} - /> -
-
- - { - remove(index); + {fields.map((field, index) => { + // Get the error for this specific entry + const entryError = errors?.[index]; + + return ( +
+
+ + +
+
+ { + // Check for an error on this specific property + const specificError = entryError?.name; + const hasError = !!fieldState.error || !!specificError || !!errors?.root?.message; + + return ( + { + const value = e.target.value; + field.onChange(value); + }} + onBlur={async () => { + field.onBlur(); + + // Trigger validation for the entire array to check for duplicate keys + if (fields.length > 1) { + await trigger(`${baseName}.fileVolumes`); + } + }} + isInvalid={hasError} + errorMessage={ + fieldState.error?.message || + specificError?.message || + (errors?.root?.message && index === 0 ? errors.root.message : undefined) + } + /> + ); }} />
-
-
- -
- - { - setValue(`${baseName}.fileVolumes.${index}.content`, content); +
+ { + // Check for an error on this specific property + const specificError = entryError?.mountingPoint; + const hasError = !!fieldState.error || !!specificError; + + return ( + { + const value = e.target.value; + field.onChange(value); + }} + onBlur={async () => { + field.onBlur(); + + // Trigger validation for the entire array to check for duplicate keys + if (fields.length > 1) { + await trigger(`${baseName}.fileVolumes`); + } + }} + isInvalid={hasError} + errorMessage={fieldState.error?.message || specificError?.message} + /> + ); }} - error={!errors ? undefined : errors[index]?.content?.message} />
- ); - }) - )} -
- - {fields.length < 50 && ( -
-
{ - append({ name: '', mountingPoint: '', content: '' }); - }} - > - Add -
- {fields.length > 1 && ( -
{ - try { - const confirmed = await confirm(
Are you sure you want to remove all entries?
); - - if (!confirmed) { - return; - } - - for (let i = fields.length - 1; i >= 0; i--) { - remove(i); - } - } catch (error) { - console.error('Error removing all entries:', error); - toast.error('Failed to remove all entries.'); - } - }} - > - Remove all + { + remove(index); + }} + />
- )} -
- )} + +
+
+ +
+ + { + setValue(`${baseName}.fileVolumes.${index}.content`, content); + }} + error={!errors ? undefined : errors[index]?.content?.message} + /> +
+
+ ); + })} + + append({ name: '', mountingPoint: '', content: '' })} + fieldsLength={fields.length} + maxFields={50} + remove={remove} + />
); } diff --git a/src/shared/jobs/KeyValueEntriesSection.tsx b/src/shared/jobs/KeyValueEntriesSection.tsx index a9a09ec6..d4af503b 100644 --- a/src/shared/jobs/KeyValueEntriesSection.tsx +++ b/src/shared/jobs/KeyValueEntriesSection.tsx @@ -1,8 +1,6 @@ -import { InteractionContextType, useInteractionContext } from '@lib/contexts/interaction'; import { isKeySecret } from '@lib/utils'; import Label from '@shared/Label'; import StyledInput from '@shared/StyledInput'; -import StyledTextarea from '@shared/StyledTextarea'; import { KeyValueEntryWithId } from '@typedefs/deeploys'; import { useEffect, useState } from 'react'; import { @@ -13,10 +11,8 @@ import { UseFieldArrayRemove, useFormContext, } from 'react-hook-form'; -import { toast } from 'react-hot-toast'; -import { RiAddLine } from 'react-icons/ri'; import SecretValueToggle from './SecretValueToggle'; -import ValueTypeToggle from './ValueTypeToggle'; +import VariableSectionControls from './VariableSectionControls'; import VariableSectionIndex from './VariableSectionIndex'; import VariableSectionRemove from './VariableSectionRemove'; @@ -25,12 +21,11 @@ export default function KeyValueEntriesSection({ name, displayLabel = 'entries', label, - maxEntries, + maxEntries = 50, predefinedEntries, disabledKeys, placeholders = ['KEY', 'VALUE'], enableSecretValues = false, - enableJsonValues = false, parentMethods, }: { name: string; @@ -41,15 +36,13 @@ export default function KeyValueEntriesSection({ disabledKeys?: string[]; placeholders?: [string, string]; enableSecretValues?: boolean; - enableJsonValues?: boolean; parentMethods?: { fields: Record<'id', string>[]; append: UseFieldArrayAppend; remove: UseFieldArrayRemove; }; }) { - const { confirm } = useInteractionContext() as InteractionContextType; - const { control, formState, trigger, setValue } = useFormContext(); + const { control, formState, trigger } = useFormContext(); const { fields, append, remove } = parentMethods ?? @@ -62,7 +55,6 @@ export default function KeyValueEntriesSection({ const entries = fields as KeyValueEntryWithId[]; const [isFieldSecret, setFieldSecret] = useState<{ [id: string]: boolean }>({}); - const [fieldValueType, setFieldValueType] = useState<{ [id: string]: 'text' | 'json' }>({}); useEffect(() => { entries.forEach((entry) => { @@ -72,55 +64,54 @@ export default function KeyValueEntriesSection({ [entry.id]: isKeySecret(entry.key), })); } - if (fieldValueType[entry.id] === undefined) { - setFieldValueType((previous) => ({ - ...previous, - [entry.id]: entry.valueType || 'text', - })); - } }); }, [entries]); // Get array-level errors - const key = name.split('.')[1]; - const deploymentErrors = formState.errors.deployment as any; - const errors = deploymentErrors?.[key]; + const errors = name.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined; + } + + return (acc as Record)[segment]; + }, formState.errors as unknown) as any; return (
-
- {!!label && ( -
-
- )} - -
- {!!predefinedEntries && predefinedEntries.length > 0 && ( - <> - {predefinedEntries.map((entry, index) => ( -
- - - {enableSecretValues && } + {(entries.length > 0 || (!!predefinedEntries && predefinedEntries.length > 0)) && ( +
+ {!!label && ( +
+
+ )} -
- - +
+ {!!predefinedEntries && predefinedEntries.length > 0 && ( + <> + {predefinedEntries.map((entry, index) => ( +
+ + + {enableSecretValues && ( + + )} + +
+ + +
+ + {/* Displayed for styling purposes */} +
+ {}} /> +
+ ))} + + )} -
- {}} /> -
-
- ))} - - )} - - {entries.length === 0 ? ( -
No {displayLabel} added yet.
- ) : ( - entries.map((entry: KeyValueEntryWithId, index) => { + {entries.map((entry: KeyValueEntryWithId, index) => { // Get the error for this specific entry const entryError = errors?.[index]; @@ -140,20 +131,6 @@ export default function KeyValueEntriesSection({ /> )} - {enableJsonValues && ( - { - const newType = fieldValueType[entry.id] === 'text' ? 'json' : 'text'; - setFieldValueType((previous) => ({ - ...previous, - [entry.id]: newType, - })); - setValue(`${name}.${index}.valueType`, newType, { shouldValidate: true }); - }} - /> - )} -
{ - const value = e.target.value; - field.onChange(value); - }} - onBlur={async () => { - field.onBlur(); - // Try to format JSON on blur - if (field.value && field.value.trim()) { - try { - const parsed = JSON.parse(field.value); - const formatted = JSON.stringify(parsed, null, 2); - field.onChange(formatted); - } catch { - // Keep original value if JSON is invalid - } - } - }} - isInvalid={hasError} - errorMessage={fieldState.error?.message || specificValueError?.message} - minRows={3} - maxRows={10} - /> - ) : ( + return ( { - const next = { ...previous }; - delete next[entry.id]; - return next; - }); }} />
); - }) - )} -
-
- - {(maxEntries === undefined || entries.length < maxEntries) && ( -
-
{ - append({ key: '', value: '', valueType: 'text' }); - }} - > - Add + })}
- - {entries.length > 1 && ( -
{ - try { - const confirmed = await confirm(
Are you sure you want to remove all entries?
); - - if (!confirmed) { - return; - } - - for (let i = entries.length - 1; i >= 0; i--) { - remove(i); - } - } catch (error) { - console.error('Error removing all entries:', error); - toast.error('Failed to remove all entries.'); - } - }} - > - Remove all -
- )}
)} + + append({ key: '', value: '' })} + fieldsLength={entries.length} + maxFields={maxEntries} + remove={remove} + />
); } diff --git a/src/shared/jobs/SelectGPU.tsx b/src/shared/jobs/SelectGPU.tsx index ed932edf..b2dcdf85 100644 --- a/src/shared/jobs/SelectGPU.tsx +++ b/src/shared/jobs/SelectGPU.tsx @@ -35,7 +35,7 @@ export default function SelectGPU({ jobType, isDisabled }: Props) { setSupportedGpuTypes(supportedGpuTypes); if (!supportedGpuTypes.length) { - setValue('specifications.gpuType', ''); + setValue('specifications.gpuType', undefined); } } }, [containerOrWorkerTypeValue]); diff --git a/src/shared/jobs/ServiceInputsSection.tsx b/src/shared/jobs/ServiceInputsSection.tsx index ced281ef..6b90af7a 100644 --- a/src/shared/jobs/ServiceInputsSection.tsx +++ b/src/shared/jobs/ServiceInputsSection.tsx @@ -1,16 +1,12 @@ +import { generateSecurePassword, isKeySecret } from '@lib/utils'; import { SlateCard } from '@shared/cards/SlateCard'; import InputWithLabel from '@shared/InputWithLabel'; import { KeyValueEntryWithId } from '@typedefs/deeploys'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; +import DeeployInfoAlert from './DeeployInfoAlert'; -export default function ServiceInputsSection({ - inputs, - isEditingJob, -}: { - inputs: { key: string; label: string }[]; - isEditingJob?: boolean; -}) { +export default function ServiceInputsSection({ inputs }: { inputs: { key: string; label: string }[] }) { const { control, setValue } = useFormContext(); const { fields } = useFieldArray({ @@ -20,21 +16,27 @@ export default function ServiceInputsSection({ const typedFields = fields as KeyValueEntryWithId[]; + const [hasGenerated, setGenerated] = useState(false); + useEffect(() => { - setValue( - 'deployment.inputs', - inputs.map((input) => { - // let value = ''; + // If a job/job draft is not being edited and we haven't attempted to auto-generate passwords yet + if (!fields.length && !hasGenerated) { + setValue( + 'deployment.inputs', + inputs.map((input) => { + let value = ''; + + if (isKeySecret(input.key)) { + value = generateSecurePassword(); + } - if (!isEditingJob && input.key.toLowerCase().includes('password')) { - // TODO: Only generate it if no default value (editing flow) exists - // value = generateSecurePassword(); - } + return { key: input.key, value }; + }), + ); - return { key: input.key, value: '' }; - }), - ); - }, [inputs]); + setGenerated(true); + } + }, [inputs, fields]); return ( @@ -46,14 +48,18 @@ export default function ServiceInputsSection({ name={`deployment.inputs.${index}.value`} label={inputs[index].label} placeholder="Required" + endContent={isKeySecret(field.key) ? 'copy' : undefined} + hasSecretValue={isKeySecret(field.key)} />
))}
- {/* TODO: Display after the input becomes dirty */} - {inputs.some((input) => input.key.toLowerCase().includes('password')) && ( -
Don't forget to save your auto-generated password.
+ {hasGenerated && ( + )}
diff --git a/src/shared/jobs/SpecsNodesSection.tsx b/src/shared/jobs/SpecsNodesSection.tsx index 4ef9a68f..1418c266 100644 --- a/src/shared/jobs/SpecsNodesSection.tsx +++ b/src/shared/jobs/SpecsNodesSection.tsx @@ -1,23 +1,21 @@ -import { APPLICATION_TYPES } from '@data/applicationTypes'; import { ContainerOrWorkerType, genericContainerTypes, nativeWorkerTypes } from '@data/containerResources'; import { SlateCard } from '@shared/cards/SlateCard'; import NumberInputWithLabel from '@shared/NumberInputWithLabel'; -import SelectWithLabel from '@shared/SelectWithLabel'; import { JobType } from '@typedefs/deeploys'; import { useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { RiErrorWarningLine } from 'react-icons/ri'; -import DeeployWarning from './DeeployWarning'; +import DeeployWarningAlert from './DeeployWarningAlert'; import JobTags from './target-nodes/JobTags'; export default function SpecsNodesSection({ jobType, - isEditingJob = false, + isEditingRunningJob = false, initialTargetNodesCount, onTargetNodesCountDecrease, }: { jobType: JobType; - isEditingJob?: boolean; + isEditingRunningJob?: boolean; initialTargetNodesCount?: number; onTargetNodesCountDecrease?: (blocked: boolean) => void; }) { @@ -71,7 +69,7 @@ export default function SpecsNodesSection({ useEffect(() => { const initialValue = initialTargetNodesCountRef.current; - const isValueLower = isEditingJob && !!initialValue && targetNodesCount < initialValue; + const isValueLower = isEditingRunningJob && !!initialValue && targetNodesCount < initialValue; setShowDecreaseWarning((previous) => { if (previous === isValueLower) { @@ -82,7 +80,7 @@ export default function SpecsNodesSection({ }); onTargetNodesCountDecrease?.(isValueLower); - }, [isEditingJob, onTargetNodesCountDecrease, targetNodesCount]); + }, [isEditingRunningJob, onTargetNodesCountDecrease, targetNodesCount]); const hasMinimalBalancingWarning = !containerOrWorkerType ? false @@ -105,11 +103,11 @@ export default function SpecsNodesSection({ hasWarning={hasWarning} /> - + /> */}
@@ -132,7 +130,7 @@ export default function SpecsNodesSection({ )} {hasMinimalBalancingWarning && ( - The minimal recommended balancing is{' '} diff --git a/src/shared/jobs/ValueTypeToggle.tsx b/src/shared/jobs/ValueTypeToggle.tsx deleted file mode 100644 index d3560cee..00000000 --- a/src/shared/jobs/ValueTypeToggle.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import clsx from 'clsx'; -import { RiCodeBoxLine, RiText } from 'react-icons/ri'; - -export default function ValueTypeToggle({ - valueType = 'text', - isDisabled, - useFixedHeight = true, - onClick, -}: { - valueType?: 'text' | 'json'; - isDisabled?: boolean; - useFixedHeight?: boolean; - onClick?: () => void; -}) { - return ( -
{ - if (isDisabled) { - return; - } - - onClick?.(); - }} - title={valueType === 'text' ? 'Switch to JSON' : 'Switch to Text'} - > -
- {valueType === 'text' ? : } -
-
- ); -} diff --git a/src/shared/jobs/VariableSectionControls.tsx b/src/shared/jobs/VariableSectionControls.tsx new file mode 100644 index 00000000..584e6b09 --- /dev/null +++ b/src/shared/jobs/VariableSectionControls.tsx @@ -0,0 +1,74 @@ +import { Button } from '@heroui/button'; +import { InteractionContextType, useInteractionContext } from '@lib/contexts/interaction'; +import toast from 'react-hot-toast'; +import { RiAddLine, RiDeleteBin2Line } from 'react-icons/ri'; + +export default function VariableSectionControls({ + displayLabel, + addLabel, + onClick, + fieldsLength, + maxFields, + remove, +}: { + displayLabel: string; + addLabel?: string; + onClick: () => void; + fieldsLength: number; + maxFields: number; + remove: (index?: number | number[]) => void; +}) { + const { confirm } = useInteractionContext() as InteractionContextType; + + return ( +
+
+ {!fieldsLength &&
No {displayLabel} added yet.
} + + {fieldsLength > 1 && ( +
{ + try { + const confirmed = await confirm(
Are you sure you want to remove all entries?
); + + if (!confirmed) { + return; + } + + for (let i = fieldsLength - 1; i >= 0; i--) { + remove(i); + } + } catch (error) { + console.error('Error removing all entries:', error); + toast.error('Failed to remove all entries.'); + } + }} + > +
+ +
Remove all
+
+
+ )} +
+ + {fieldsLength < maxFields && ( +
+ +
+ )} +
+ ); +} diff --git a/src/shared/jobs/deployment-type/DeploymentTypeSectionCard.tsx b/src/shared/jobs/deployment-type/DeploymentTypeSectionCard.tsx index 34585cbc..c87433ff 100644 --- a/src/shared/jobs/deployment-type/DeploymentTypeSectionCard.tsx +++ b/src/shared/jobs/deployment-type/DeploymentTypeSectionCard.tsx @@ -2,66 +2,66 @@ import { CR_VISIBILITY_OPTIONS } from '@data/crVisibilityOptions'; import { Switch } from '@heroui/switch'; import { SlateCard } from '@shared/cards/SlateCard'; import { SmallTag } from '@shared/SmallTag'; -import { useState } from 'react'; +import { DeploymentType, PluginType } from '@typedefs/steps/deploymentStepTypes'; import { useFormContext } from 'react-hook-form'; import ContainerSection from './ContainerSection'; import WorkerSection from './WorkerSection'; -function DeploymentTypeSectionCard({ isEditingJob }: { isEditingJob?: boolean }) { +function DeploymentTypeSectionCard({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) { const { setValue, watch } = useFormContext(); - const deploymentType = watch('deployment.deploymentType'); - const [type, setType] = useState<'worker' | 'container'>(deploymentType.type); + const deploymentType: DeploymentType = watch('deployment.deploymentType'); + const pluginType: PluginType = deploymentType.pluginType; - const onDeploymentTypeChange = (isContainer: boolean) => { - const type = isContainer ? 'container' : 'worker'; + const onDeploymentTypeChange = (isGenericPluginTypeSelected: boolean) => { + const selectedPluginType: PluginType = isGenericPluginTypeSelected ? PluginType.Container : PluginType.Worker; - setType(type); - - if (type === 'container') { + if (selectedPluginType === PluginType.Container) { setValue('deployment.deploymentType', { - type: 'container', + pluginType: selectedPluginType, containerImage: '', containerRegistry: 'docker.io', crVisibility: CR_VISIBILITY_OPTIONS[0], crUsername: '', crPassword: '', - ports: {}, }); } else { setValue('deployment.deploymentType', { - type: 'worker', + pluginType: selectedPluginType, image: 'node:22', repositoryUrl: '', username: '', accessToken: '', workerCommands: [{ command: 'npm install' }, { command: 'npm run build' }, { command: 'npm run start' }], - ports: {}, }); } }; return ( - Worker + Worker - Container + Container
} > - {type === 'container' ? : } + {pluginType === PluginType.Container ? ( + + ) : ( + + )} ); } diff --git a/src/shared/jobs/deployment-type/WorkerCommandsSection.tsx b/src/shared/jobs/deployment-type/WorkerCommandsSection.tsx index bfb15da8..e29d66ed 100644 --- a/src/shared/jobs/deployment-type/WorkerCommandsSection.tsx +++ b/src/shared/jobs/deployment-type/WorkerCommandsSection.tsx @@ -1,28 +1,34 @@ import Label from '@shared/Label'; import StyledInput from '@shared/StyledInput'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; -import { RiAddLine } from 'react-icons/ri'; +import VariableSectionControls from '../VariableSectionControls'; import VariableSectionIndex from '../VariableSectionIndex'; import VariableSectionRemove from '../VariableSectionRemove'; // This component assumes it's being used in the deployment step export default function WorkerCommandsSection({ baseName }: { baseName: string }) { + const name = `${baseName}.deploymentType.workerCommands`; + const { control, formState, trigger } = useFormContext(); const { fields, append, remove } = useFieldArray({ control, - name: `${baseName}.deploymentType.workerCommands`, + name, }); // Get array-level errors - const errors = (formState.errors[baseName] as any)?.deploymentType?.workerCommands; + const errors = name.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined; + } + + return (acc as Record)[segment]; + }, formState.errors as unknown) as any; return (
); } diff --git a/src/shared/jobs/deployment-type/WorkerSection.tsx b/src/shared/jobs/deployment-type/WorkerSection.tsx index f36b4b37..2e9b7b42 100644 --- a/src/shared/jobs/deployment-type/WorkerSection.tsx +++ b/src/shared/jobs/deployment-type/WorkerSection.tsx @@ -10,10 +10,10 @@ import WorkerCommandsSection from './WorkerCommandsSection'; const REPOS_CACHE: Record = {}; export default function WorkerSection({ - isEditingJob, + isEditingRunningJob, baseName = 'deployment', }: { - isEditingJob?: boolean; + isEditingRunningJob?: boolean; baseName?: string; }) { const { watch, setValue, register } = useFormContext(); @@ -26,10 +26,10 @@ export default function WorkerSection({ }, [register]); useEffect(() => { - if (isEditingJob && repositoryUrl) { + if (isEditingRunningJob && repositoryUrl) { checkRepositoryVisibility(repositoryUrl); } - }, [isEditingJob, repositoryUrl]); + }, [isEditingRunningJob, repositoryUrl]); const checkRepositoryVisibility = async (value?: string) => { const url = value ?? repositoryUrl; @@ -98,7 +98,7 @@ export default function WorkerSection({
) : null } - displayPasteIcon + endContent="paste" /> @@ -130,7 +130,7 @@ export default function WorkerSection({ label="Personal Access Token" placeholder="None" isOptional={repositoryVisibility === 'public'} - displayPasteIcon + endContent="paste" />
diff --git a/src/shared/jobs/native/CustomParametersSection.tsx b/src/shared/jobs/native/CustomParametersSection.tsx new file mode 100644 index 00000000..30e65109 --- /dev/null +++ b/src/shared/jobs/native/CustomParametersSection.tsx @@ -0,0 +1,206 @@ +import CustomTabs from '@shared/CustomTabs'; +import JsonEditor from '@shared/JsonEditor'; +import StyledInput from '@shared/StyledInput'; +import { CustomParameterEntry } from '@typedefs/steps/deploymentStepTypes'; +import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import VariableSectionControls from '../VariableSectionControls'; +import VariableSectionIndex from '../VariableSectionIndex'; +import VariableSectionRemove from '../VariableSectionRemove'; + +type CustomParameterEntryWithId = CustomParameterEntry & { + id: string; +}; + +const PLACEHOLDERS = ['KEY', 'VALUE']; + +export default function CustomParametersSection({ baseName = 'deployment' }: { baseName?: string }) { + const { control, formState, trigger } = useFormContext(); + + const name = `${baseName}.customParams`; + + const { fields, append, remove } = useFieldArray({ + control, + name, + }); + + // Explicitly type the fields to match the expected structure + const entries = fields as CustomParameterEntryWithId[]; + + // Get array-level errors + const errors = name.split('.').reduce((acc, segment) => { + if (!acc || typeof acc !== 'object') { + return undefined; + } + + return (acc as Record)[segment]; + }, formState.errors as unknown) as any; + + const watchedEntries = useWatch({ + control, + name, + }) as CustomParameterEntry[] | undefined; + + return ( +
+ {entries.map((entry: CustomParameterEntryWithId, index) => { + // Get the error for this specific entry + const entryError = errors?.[index]; + const currentEntry = watchedEntries?.[index]; + const valueType = currentEntry?.valueType ?? entry.valueType; + + return ( +
+
+ + +
+ ( + { + field.onChange(selectedKey); + }} + isCompact + /> + )} + /> + + { + // Check for specific error on this key input or array-level error + const specificKeyError = entryError?.key; + const hasError = !!fieldState.error || !!specificKeyError || !!errors?.root?.message; + + return ( + { + const value = e.target.value; + field.onChange(value); + }} + onBlur={async () => { + field.onBlur(); + + // Trigger validation for the entire array to check for duplicate keys + if (entries.length > 1) { + await trigger(name); + } + }} + isInvalid={hasError} + errorMessage={ + fieldState.error?.message || + specificKeyError?.message || + (errors?.root?.message && index === 0 ? errors.root.message : undefined) + } + /> + ); + }} + /> +
+ + { + remove(index); + }} + /> +
+ +
+ {/* Displayed for styling purposes */} +
+ +
+ +
+ {valueType === 'string' ? ( + { + // Check for specific error on this value input + const specificValueError = entryError?.value; + const hasError = !!fieldState.error || !!specificValueError; + + return ( + { + const value = e.target.value; + field.onChange(value); + }} + onBlur={async () => { + field.onBlur(); + }} + isInvalid={hasError} + errorMessage={fieldState.error?.message || specificValueError?.message} + /> + ); + }} + /> + ) : ( + { + const specificValueError = entryError?.value; + const errorMessage: string | undefined = + fieldState.error?.message || specificValueError?.message; + + return ( +
+ { + field.onChange(value); + }} + onBlur={() => { + field.onBlur(); + void trigger(`${name}.${index}.value`); + }} + errorMessage={errorMessage} + /> +
+ ); + }} + /> + )} +
+ + {/* Displayed for styling purposes */} +
+ {}} /> +
+
+
+ ); + })} + + append({ key: '', value: '', valueType: 'string' })} + fieldsLength={entries.length} + maxFields={50} + remove={remove} + /> +
+ ); +} diff --git a/src/shared/jobs/native/NativeAppIdentitySection.tsx b/src/shared/jobs/native/NativeAppIdentitySection.tsx index d0f9a037..8de0014f 100644 --- a/src/shared/jobs/native/NativeAppIdentitySection.tsx +++ b/src/shared/jobs/native/NativeAppIdentitySection.tsx @@ -9,8 +9,6 @@ export default function NativeAppIdentitySection({ pluginSignature: (typeof PLUGIN_SIGNATURE_TYPES)[number]; baseName?: string; }) { - const getJobAlias = () => ; - const getPluginSignature = () => ( ); @@ -23,18 +21,9 @@ export default function NativeAppIdentitySection({ return ; }; - return baseName !== 'deployment' ? ( + return (
{getPluginSignature()} - {getCustomPluginSignature()} -
- ) : ( -
-
- {getJobAlias()} - {getPluginSignature()} -
- {getCustomPluginSignature()}
); diff --git a/src/shared/jobs/target-nodes/SpareNodesSection.tsx b/src/shared/jobs/target-nodes/SpareNodesSection.tsx index 2e5a9a29..7ac13e00 100644 --- a/src/shared/jobs/target-nodes/SpareNodesSection.tsx +++ b/src/shared/jobs/target-nodes/SpareNodesSection.tsx @@ -1,6 +1,8 @@ import StyledInput from '@shared/StyledInput'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; -import { RiAddLine, RiClipboardLine } from 'react-icons/ri'; +import { RiClipboardLine } from 'react-icons/ri'; +import DeeployInfoTag from '../DeeployInfoTag'; +import VariableSectionControls from '../VariableSectionControls'; import VariableSectionIndex from '../VariableSectionIndex'; import VariableSectionRemove from '../VariableSectionRemove'; @@ -17,13 +19,9 @@ export default function SpareNodesSection() { return (
-
- You can specify spare nodes to be used as backup in case the above specified target nodes are not available. -
+ - {!fields.length ? ( -
No spare nodes added yet.
- ) : ( + {fields.length > 0 && (
{fields.map((field, index) => { // Get the error for this specific entry @@ -91,14 +89,14 @@ export default function SpareNodesSection() {
)} - {fields.length < 25 && ( -
append({ address: '' })} - > - Add Node -
- )} + append({ address: '' })} + fieldsLength={fields.length} + maxFields={25} + remove={remove} + />
); } diff --git a/src/shared/jobs/target-nodes/TargetNodesCard.tsx b/src/shared/jobs/target-nodes/TargetNodesCard.tsx index 64796057..78ac651a 100644 --- a/src/shared/jobs/target-nodes/TargetNodesCard.tsx +++ b/src/shared/jobs/target-nodes/TargetNodesCard.tsx @@ -8,7 +8,7 @@ import { useFormContext } from 'react-hook-form'; import { RiAsterisk } from 'react-icons/ri'; import SpareNodesSection from './SpareNodesSection'; -function TargetNodesCard({ isEditingJob }: { isEditingJob?: boolean }) { +function TargetNodesCard({ isEditingRunningJob }: { isEditingRunningJob?: boolean }) { const { watch } = useFormContext(); const { setValue } = useFormContext(); @@ -20,7 +20,7 @@ function TargetNodesCard({ isEditingJob }: { isEditingJob?: boolean }) { const allowReplicationInTheWild: boolean = watch('deployment.allowReplicationInTheWild'); useEffect(() => { - if (isEditingJob) { + if (isEditingRunningJob) { setValue('deployment.targetNodes', [ ...targetNodes, ...Array.from({ length: targetNodesCount - targetNodes.length }, () => ({ address: '' })), @@ -41,7 +41,7 @@ function TargetNodesCard({ isEditingJob }: { isEditingJob?: boolean }) { { @@ -66,10 +66,13 @@ function TargetNodesCard({ isEditingJob }: { isEditingJob?: boolean }) { { - setValue('deployment.allowReplicationInTheWild', value); + setValue('deployment.allowReplicationInTheWild', value, { shouldDirty: true }); }} > -
Allow deployment beyond chosen nodes
+
+
Allow deployment beyond chosen nodes
+ +
diff --git a/src/shared/jobs/target-nodes/TargetNodesSection.tsx b/src/shared/jobs/target-nodes/TargetNodesSection.tsx index fabba1af..6e1220c7 100644 --- a/src/shared/jobs/target-nodes/TargetNodesSection.tsx +++ b/src/shared/jobs/target-nodes/TargetNodesSection.tsx @@ -2,6 +2,7 @@ import { TARGET_NODES_REQUIRED_ERROR } from '@schemas/index'; import StyledInput from '@shared/StyledInput'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { RiAddLine, RiClipboardLine } from 'react-icons/ri'; +import DeeployInfoTag from '../DeeployInfoTag'; import VariableSectionIndex from '../VariableSectionIndex'; // This component assumes it's being used in the deployment step @@ -19,17 +20,19 @@ export default function TargetNodesSection({ autoAssign }: { autoAssign: boolean return (
-
- {autoAssign ? ( - <> - Your app will be deployed to{' '} - {targetNodesCount > 1 ? targetNodesCount : 'one'}{' '} - arbitrary available node{targetNodesCount > 1 ? 's' : ''}. - - ) : ( - <>Your app will be deployed to the nodes you specify below. - )} -
+ + Your app will be deployed to{' '} + {targetNodesCount > 1 ? targetNodesCount : 'one'}{' '} + arbitrary available node{targetNodesCount > 1 ? 's' : ''}. + + ) : ( + <>Your app will be deployed to the nodes you specify below. + ) + } + /> {!autoAssign && ( <> diff --git a/src/shared/projects/AddJobCard.tsx b/src/shared/projects/AddJobCard.tsx index b66de80f..0852731a 100644 --- a/src/shared/projects/AddJobCard.tsx +++ b/src/shared/projects/AddJobCard.tsx @@ -1,10 +1,18 @@ import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment'; import ActionButton from '@shared/ActionButton'; import { BorderedCard } from '@shared/cards/BorderedCard'; -import { jobTypeOptions } from '@typedefs/jobType'; +import { JOB_TYPE_OPTIONS } from '@typedefs/jobType'; import { RiAddLine } from 'react-icons/ri'; -export default function AddJobCard() { +export default function AddJobCard({ + type = 'job', + options = JOB_TYPE_OPTIONS, + customCallback, +}: { + type?: 'job' | 'plugin'; + options?: any[]; + customCallback?: (option) => void; +}) { const { setJobType, setStep } = useDeploymentContext() as DeploymentContextType; return ( @@ -12,19 +20,22 @@ export default function AddJobCard() {
-
Add Job
+
Add {type}
- {jobTypeOptions.map((option) => ( + {options.map((option: any, index) => ( { - // Job type selection is considered to be the 1st step - setStep(0); - setJobType(option.jobType); + if (customCallback) { + customCallback(option); + } else { + setStep(0); + setJobType(option.jobType); + } }} >
diff --git a/src/typedefs/deeployApi.ts b/src/typedefs/deeployApi.ts index 366877c3..4c057d14 100644 --- a/src/typedefs/deeployApi.ts +++ b/src/typedefs/deeployApi.ts @@ -1,4 +1,5 @@ import { DYNAMIC_ENV_TYPES } from '@data/dynamicEnvTypes'; +import { PIPELINE_INPUT_TYPES } from '@data/pipelineInputTypes'; import { EthAddress, R1Address } from './blockchain'; type Apps = { @@ -9,6 +10,7 @@ type Apps = { last_config: string; // ISO-like timestamp string is_deeployed: boolean; deeploy_specs: DeeploySpecs; + pipeline_data: PipelineData; plugins: { [pluginName: string]: AppsPlugin[]; }; @@ -21,6 +23,9 @@ type DeeploySpecs = { date_created: number; date_updated: number; initial_target_nodes: R1Address[]; + job_config?: { + pipeline_params?: Record; + }; job_id: number; job_tags: string[]; nr_target_nodes: number; @@ -29,6 +34,24 @@ type DeeploySpecs = { spare_nodes: R1Address[]; }; +type PipelineData = { + APP_ALIAS: string; + INITIATOR_ADDR: R1Address; + INITIATOR_ID: string; + IS_DEEPLOYED: boolean; + LAST_UPDATE_TIME: string; // ISO-like timestamp string + LIVE_FEED: boolean; + MODIFIED_BY_ADDR: R1Address; + MODIFIED_BY_ID: string; + NAME: string; + OWNER: EthAddress; + SESSION_ID: string; + TIME: string; // ISO-like timestamp string + TYPE: (typeof PIPELINE_INPUT_TYPES)[number]; + URL?: string; + VALIDATED: boolean; +}; + type AppsPlugin = { instance: string; start: string; // ISO-like timestamp string @@ -53,10 +76,12 @@ type JobConfig = { IMAGE: string; IMAGE_PULL_POLICY?: string; INSTANCE_ID: string; - NGROK_USE_API: boolean; + NGROK_AUTH_TOKEN?: string; + NGROK_EDGE_LABEL?: string; + NGROK_USE_API?: boolean; // Deprecated, used for backwards compatibility PORT: number; RESTART_POLICY?: string; - TUNNEL_ENGINE: string; + TUNNEL_ENGINE: 'cloudflare' | 'ngrok'; TUNNEL_ENGINE_ENABLED: boolean; VOLUMES: Record; FILE_VOLUMES: Record< @@ -119,4 +144,5 @@ export type { JobConfig, JobConfigCRData, JobConfigVCSData, + PipelineData, }; diff --git a/src/typedefs/deeploys.ts b/src/typedefs/deeploys.ts index dc201bf8..95866e65 100644 --- a/src/typedefs/deeploys.ts +++ b/src/typedefs/deeploys.ts @@ -7,7 +7,7 @@ import { serviceContainerTypes, } from '@data/containerResources'; import { EthAddress, R1Address } from './blockchain'; -import { AppsPlugin, JobConfig } from './deeployApi'; +import { AppsPlugin, JobConfig, PipelineData } from './deeployApi'; import { GenericJobDeployment, JobDeployment, NativeJobDeployment, ServiceJobDeployment } from './steps/deploymentStepTypes'; enum JobType { @@ -23,7 +23,7 @@ enum ProjectPage { // Specifications type BaseJobSpecifications = { - applicationType: (typeof APPLICATION_TYPES)[number]; + applicationType: (typeof APPLICATION_TYPES)[number]; // Disabled for now targetNodesCount: number; jobTags: string[]; nodesCountries: string[]; @@ -106,6 +106,7 @@ type RunningJob = { balance: bigint; lastAllocatedEpoch: bigint; activeNodes: readonly EthAddress[]; + pipelineParams?: Record; }; type RunningJobWithDetails = RunningJob & { @@ -120,6 +121,7 @@ type RunningJobWithDetails = RunningJob & { plugins: (AppsPlugin & { signature: string })[]; }[]; config: JobConfig; + pipelineData: PipelineData; }; type RunningJobWithResources = RunningJobWithDetails & { @@ -130,7 +132,6 @@ export interface KeyValueEntryWithId { id: string; key: string; value: string; - valueType?: 'text' | 'json'; } export { JobType, ProjectPage }; diff --git a/src/typedefs/general.ts b/src/typedefs/general.ts index e8b981fa..bb516d99 100644 --- a/src/typedefs/general.ts +++ b/src/typedefs/general.ts @@ -1,5 +1,9 @@ import { EthAddress } from './blockchain'; +export const BRANDING_PLATFORM_NAMES = { + Linkedin: 'LinkedIn', +}; + type BillingInfo = { companyName: string; billingEmail: string; @@ -34,4 +38,10 @@ type InvoiceDraft = { cspOwnerName: string; }; -export type { AuthState, BillingInfo, InvoiceDraft, TunnelingSecrets }; +type PublicProfileInfo = { + name: string; + description: string; + links: Record; +}; + +export type { AuthState, BillingInfo, InvoiceDraft, PublicProfileInfo, TunnelingSecrets }; diff --git a/src/typedefs/jobType.tsx b/src/typedefs/jobType.tsx index 689012f8..745cfab6 100644 --- a/src/typedefs/jobType.tsx +++ b/src/typedefs/jobType.tsx @@ -10,7 +10,7 @@ export type JobTypeOption = { jobType: JobType; }; -export const jobTypeOptions: JobTypeOption[] = [ +export const JOB_TYPE_OPTIONS: JobTypeOption[] = [ { id: 'generic', title: 'Generic App', diff --git a/src/typedefs/steps/deploymentStepTypes.ts b/src/typedefs/steps/deploymentStepTypes.ts index ef203dbb..b19c4696 100644 --- a/src/typedefs/steps/deploymentStepTypes.ts +++ b/src/typedefs/steps/deploymentStepTypes.ts @@ -10,7 +10,10 @@ import { R1Address } from '@typedefs/blockchain'; type KeyValueEntry = { key: string; value: string; - valueType?: 'text' | 'json'; +}; + +type CustomParameterEntry = KeyValueEntry & { + valueType: 'string' | 'json'; }; type DynamicEnvVarsEntry = { @@ -32,43 +35,54 @@ type FileVolumesEntry = { content: string; }; +type PortMappingEntry = { + hostPort: number; + containerPort: number; +}; + // Deployment types type ContainerDeploymentType = { - type: 'container'; containerImage: string; containerRegistry: string; crVisibility: (typeof CR_VISIBILITY_OPTIONS)[number]; crUsername?: string; crPassword?: string; - ports?: Record; }; type WorkerDeploymentType = { - type: 'worker'; image: string; repositoryUrl: string; repositoryVisibility: 'public' | 'private'; username?: string; accessToken?: string; workerCommands: Array<{ command: string }>; - ports?: Record; }; -type DeploymentType = ContainerDeploymentType | WorkerDeploymentType; +type DeploymentType = (ContainerDeploymentType | WorkerDeploymentType) & { + pluginType: PluginType; +}; // Plugin-related types - -export enum SecondaryPluginType { +export enum BasePluginType { Generic = 'generic', Native = 'native', } -type GenericSecondaryPlugin = { - // Base - port?: number; +export enum PluginType { + Native = 'native', + Container = 'container', + Worker = 'worker', +} + +type GenericPlugin = { + // Tunneling + port?: number | string; enableTunneling: (typeof BOOLEAN_TYPES)[number]; tunnelingToken?: string; + // Ports + ports: Array; + // Deployment type deploymentType: DeploymentType; @@ -83,22 +97,22 @@ type GenericSecondaryPlugin = { imagePullPolicy: (typeof POLICY_TYPES)[number]; }; -type NativeSecondaryPlugin = { +type NativePlugin = { // Signature pluginSignature: (typeof PLUGIN_SIGNATURE_TYPES)[number]; customPluginSignature?: string; // Tunneling - port?: number; + port?: number | string; enableTunneling: (typeof BOOLEAN_TYPES)[number]; tunnelingToken?: string; // Custom Parameters - customParams: Array; + customParams: Array; }; -type SecondaryPlugin = (GenericSecondaryPlugin | NativeSecondaryPlugin) & { - secondaryPluginType: SecondaryPluginType; +type Plugin = (GenericPlugin | NativePlugin) & { + basePluginType: BasePluginType; }; // Deployment types @@ -109,31 +123,34 @@ type BaseJobDeployment = { spareNodes: Array<{ address: R1Address }>; allowReplicationInTheWild: boolean; enableTunneling: (typeof BOOLEAN_TYPES)[number]; - tunnelingLabel?: string; + port?: number | string; tunnelingToken?: string; + tunnelingLabel?: string; }; type GenericJobDeployment = BaseJobDeployment & { deploymentType: DeploymentType; - port?: number; + + // Ports + ports: Array; + + // Variables envVars: Array; dynamicEnvVars: Array; volumes: Array; fileVolumes: Array; + + // Policies restartPolicy: (typeof POLICY_TYPES)[number]; imagePullPolicy: (typeof POLICY_TYPES)[number]; }; type NativeJobDeployment = BaseJobDeployment & { - pluginSignature: (typeof PLUGIN_SIGNATURE_TYPES)[number]; - customPluginSignature?: string; - port?: number; - customParams: Array; pipelineParams: Array; pipelineInputType: (typeof PIPELINE_INPUT_TYPES)[number]; pipelineInputUri?: string; - chainstoreResponse: (typeof BOOLEAN_TYPES)[number]; - secondaryPlugins: SecondaryPlugin[]; + plugins: Plugin[]; + chainstoreResponse: (typeof BOOLEAN_TYPES)[number]; // Enforced to true }; type ServiceJobDeployment = BaseJobDeployment & { @@ -145,12 +162,16 @@ type JobDeployment = BaseJobDeployment & (GenericJobDeployment | NativeJobDeploy export type { BaseJobDeployment, + ContainerDeploymentType, + CustomParameterEntry, DeploymentType, GenericJobDeployment, - GenericSecondaryPlugin, + GenericPlugin, JobDeployment, NativeJobDeployment, - NativeSecondaryPlugin, - SecondaryPlugin, + NativePlugin, + Plugin, + PortMappingEntry, ServiceJobDeployment, + WorkerDeploymentType, };