From 067c52eec4b639cb29eeda77a57b1082a9d42d48 Mon Sep 17 00:00:00 2001 From: Denis <61563365+dnsi0@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:06:11 +0300 Subject: [PATCH 01/10] simplify the run job flow --- src/components/run-job/payment-authorize.tsx | 63 --- .../run-job/payment-deposit.module.css | 5 - src/components/run-job/payment-deposit.tsx | 99 ----- src/components/run-job/payment-page.tsx | 1 - src/components/run-job/payment.tsx | 164 +++----- .../run-job/select-resources.module.css | 51 +++ src/components/run-job/select-resources.tsx | 371 +++++++++--------- src/components/run-job/summary.tsx | 8 +- src/context/run-job-context.tsx | 14 +- src/lib/use-pay-session.ts | 171 ++++++++ src/types/environments.ts | 2 + 11 files changed, 485 insertions(+), 464 deletions(-) delete mode 100644 src/components/run-job/payment-authorize.tsx delete mode 100644 src/components/run-job/payment-deposit.module.css delete mode 100644 src/components/run-job/payment-deposit.tsx create mode 100644 src/lib/use-pay-session.ts diff --git a/src/components/run-job/payment-authorize.tsx b/src/components/run-job/payment-authorize.tsx deleted file mode 100644 index d501790d..00000000 --- a/src/components/run-job/payment-authorize.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import AuthorizationForm from '@/components/escrow/authorization-form'; -import Button from '@/components/button/button'; -import { SelectedToken } from '@/context/run-job-context'; -import { useAuthorizeTokens } from '@/lib/use-authorize-tokens'; -import { ComputeEnvironment } from '@/types/environments'; -import { roundTokenAmount } from '@/utils/formatters'; - -type PaymentAuthorizeProps = { - currentLockedAmount: number; - loadingPaymentInfo: boolean; - loadPaymentInfo: () => void; - minLockSeconds: number; - renderBackButton?: (disabled: boolean) => React.ReactNode; - selectedEnv: ComputeEnvironment; - selectedToken: SelectedToken; - totalCost: number; -}; - -const PaymentAuthorize = ({ - currentLockedAmount, - loadingPaymentInfo, - loadPaymentInfo, - minLockSeconds, - renderBackButton, - selectedEnv, - selectedToken, - totalCost, -}: PaymentAuthorizeProps) => { - const { handleAuthorize, isAuthorizing } = useAuthorizeTokens({ onSuccess: loadPaymentInfo }); - - return ( - - handleAuthorize({ - tokenAddress: selectedToken.address, - peerId: selectedEnv.nodeId, - spender: selectedEnv.consumerAddress, - maxLockedAmount: values.maxLockedAmount.toString(), - maxLockSeconds: values.maxLockSeconds.toString(), - maxLockCount: values.maxLockCount.toString(), - }) - } - renderSecondaryAction={renderBackButton} - renderSubmitButton={({ disabled, loading }) => ( - - )} - tokenSymbol={selectedToken.symbol} - /> - ); -}; - -export default PaymentAuthorize; diff --git a/src/components/run-job/payment-deposit.module.css b/src/components/run-job/payment-deposit.module.css deleted file mode 100644 index a8bfd788..00000000 --- a/src/components/run-job/payment-deposit.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.root { - display: flex; - flex-direction: column; - gap: 32px; -} diff --git a/src/components/run-job/payment-deposit.tsx b/src/components/run-job/payment-deposit.tsx deleted file mode 100644 index a489222c..00000000 --- a/src/components/run-job/payment-deposit.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import Button from '@/components/button/button'; -import Input from '@/components/input/input'; -import { SelectedToken } from '@/context/run-job-context'; -import { useDepositTokens } from '@/lib/use-deposit-tokens'; -import { roundTokenAmount } from '@/utils/formatters'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; -import styles from './payment-deposit.module.css'; - -type DepositFormValues = { - amount: number; -}; - -type PaymentDepositProps = { - // currentLockedAmount: number; - escrowBalance: number; - loadingPaymentInfo: boolean; - loadPaymentInfo: () => void; - renderBackButton?: (disabled: boolean) => React.ReactNode; - selectedToken: SelectedToken; - totalCost: number; - walletBalance: number; -}; - -const PaymentDeposit = ({ - // currentLockedAmount, - escrowBalance, - loadingPaymentInfo, - loadPaymentInfo, - renderBackButton, - selectedToken, - totalCost, - walletBalance, -}: PaymentDepositProps) => { - const { handleDeposit, isDepositing } = useDepositTokens({ onSuccess: loadPaymentInfo }); - - const amountToDeposit = roundTokenAmount(Math.max(0, totalCost - escrowBalance), selectedToken.address, 'up'); - const hasSufficientFunds = escrowBalance >= totalCost; - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - amount: amountToDeposit, - }, - onSubmit: async (values) => { - handleDeposit({ - tokenAddress: selectedToken.address, - amount: values.amount.toString(), - }); - }, - validateOnMount: true, - validationSchema: Yup.object({ - amount: Yup.number().required('Required').min(0, 'Invalid amount'), - }), - }); - - return ( -
- -
- {renderBackButton?.(loadingPaymentInfo)} - {hasSufficientFunds ? ( - - ) : ( - - )} -
-
- ); -}; - -export default PaymentDeposit; diff --git a/src/components/run-job/payment-page.tsx b/src/components/run-job/payment-page.tsx index df4ef05c..808da5b6 100644 --- a/src/components/run-job/payment-page.tsx +++ b/src/components/run-job/payment-page.tsx @@ -54,7 +54,6 @@ const PaymentPage = () => { void; totalCost: number; }; +const MAX_LOCK_COUNT = 10; + const Payment = ({ minLockSeconds, selectedEnv, - selectedResources, selectedToken, setPageSubtitle, totalCost, @@ -41,38 +40,9 @@ const Payment = ({ const currentLockedAmount = Number(authorizations?.currentLockedAmount ?? 0); - const step: 'topup' | 'deposit' | 'authorize' = useMemo(() => { - // TODO re-enable topup page - // if ( - // (walletBalance ?? 0) + (escrowBalance ?? 0) < totalCost && - // selectedToken.address === getSupportedTokens().USDC - // ) { - // Only USDC can be topped up with fiat - // return 'topup'; - // } - if ((escrowBalance ?? 0) < totalCost) { - return 'deposit'; - } - return 'authorize'; - }, [escrowBalance, totalCost]); - useEffect(() => { - switch (step) { - // TODO re-enable topup page - // case 'topup': { - // setPageSubtitle('You need to top up in order to start your job'); - // break; - // } - case 'deposit': { - setPageSubtitle('You need to deposit funds in escrow in order to strart your job'); - break; - } - case 'authorize': { - setPageSubtitle('Confirm and authorize your payment in order to start your job'); - break; - } - } - }, [setPageSubtitle, step]); + setPageSubtitle('Confirm and authorize your payment in order to start your job'); + }, [setPageSubtitle]); const loadPaymentInfo = useCallback(async () => { if (ocean && account?.address) { @@ -95,6 +65,7 @@ const Payment = ({ loadPaymentInfo(); }, [loadPaymentInfo]); + // Once escrow + authorization satisfy the session requirements, move on to the summary. useEffect(() => { const sufficientEscrow = (escrowBalance ?? 0) >= totalCost; const suffficientAuthorized = @@ -119,74 +90,48 @@ const Payment = ({ escrowBalance, minLockSeconds, router, - selectedResources.maxJobDurationSeconds, selectedToken.address, selectedToken.symbol, totalCost, ]); - const renderBackButton = (disabled: boolean) => ( - + const { handlePay, isPaying } = usePaySession({ onSuccess: loadPaymentInfo }); + + const depositAmount = roundTokenAmount( + Math.max(0, totalCost - (escrowBalance ?? 0)), + selectedToken.address, + 'up' ); + const maxLockedAmount = roundTokenAmount(totalCost + currentLockedAmount, selectedToken.address, 'up'); + const maxLockSeconds = minLockSeconds < 1 ? 1 : Math.ceil(minLockSeconds); + // Escrow's authorize SETS (not increments) the lock cap. Derive above the current locks so a user + // who has already used all their slots can still raise the limit and start a new session. + const maxLockCount = Math.max(MAX_LOCK_COUNT, Number(authorizations?.currentLocks ?? 0) + 1); - const renderStep = () => { - switch (step) { - // TODO re-enable topup page - // case 'topup': { - // return ( - // - // ); - // } - case 'deposit': { - return ( - - ); - } - case 'authorize': { - return ( - - ); - } - default: - return null; - } - }; + const insufficientWalletFunds = (walletBalance ?? 0) < depositAmount; + + const handleSubmit = useCallback( + () => + handlePay({ + tokenAddress: selectedToken.address, + peerId: selectedEnv.nodeId, + spender: selectedEnv.consumerAddress, + depositAmount: depositAmount.toString(), + maxLockedAmount: maxLockedAmount.toString(), + maxLockSeconds: maxLockSeconds.toString(), + maxLockCount: maxLockCount.toString(), + }), + [ + handlePay, + selectedToken.address, + selectedEnv.nodeId, + selectedEnv.consumerAddress, + depositAmount, + maxLockedAmount, + maxLockSeconds, + maxLockCount, + ] + ); return loadingPaymentInfo && (escrowBalance === null || walletBalance === null) ? ( @@ -201,7 +146,28 @@ const Payment = ({ totalCost={totalCost} walletBalance={walletBalance ?? 0} /> - {renderStep()} +
+ + +
); }; diff --git a/src/components/run-job/select-resources.module.css b/src/components/run-job/select-resources.module.css index db931fd6..2164ec4b 100644 --- a/src/components/run-job/select-resources.module.css +++ b/src/components/run-job/select-resources.module.css @@ -40,6 +40,57 @@ } } +.gpuHeader { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; +} + +.gpuHeaderEnd { + align-items: center; + display: flex; + gap: 12px; +} + +.gpuHint { + color: var(--text-secondary); + font-size: 14px; +} + +.wholeEnvNote { + color: var(--text-secondary); + font-size: 14px; +} + +.derivedGrid { + display: grid; + gap: 16px; + grid-template-columns: 1fr; + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.derivedCard { + .derivedLabel { + color: var(--text-secondary); + font-size: 14px; + } + + .derivedValue { + font-size: 24px; + font-weight: 700; + } + + .derivedHint { + color: var(--text-secondary); + font-size: 13px; + } +} + .costCard { .costEstimation { align-items: center; diff --git a/src/components/run-job/select-resources.tsx b/src/components/run-job/select-resources.tsx index da18b172..6b5be8eb 100644 --- a/src/components/run-job/select-resources.tsx +++ b/src/components/run-job/select-resources.tsx @@ -3,14 +3,12 @@ import Card from '@/components/card/card'; import GpuLabel from '@/components/gpu-label/gpu-label'; import useEnvResources from '@/components/hooks/use-env-resources'; import DurationInput from '@/components/input/duration-input'; -import Input from '@/components/input/input'; -import Select from '@/components/input/select'; import Slider from '@/components/slider/slider'; import config from '@/config'; import { SelectedToken, useRunJobContext } from '@/context/run-job-context'; import { useP2P } from '@/contexts/P2PContext'; import { useOceanAccount } from '@/lib/use-ocean-account'; -import { ComputeEnvironment } from '@/types/environments'; +import { ComputeEnvironment, ComputeResource } from '@/types/environments'; import { DURATION_UNIT_OPTIONS } from '@/utils/duration'; import { formatDuration, formatTokenAmount, roundTokenAmount } from '@/utils/formatters'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; @@ -31,11 +29,16 @@ type SelectResourcesProps = { }; type ResourcesFormValues = { - cpuCores: number; - diskSpace: number | ''; - gpus: string[]; + gpuCount: number; maxJobDurationSeconds: number; - ram: number; +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +// Full env capacity for a resource. Prefer `total` (whole-env capacity); fall back to `max`. +const capacityOf = (resource?: ComputeResource) => { + const total = resource?.total ?? 0; + return total > 0 ? total : (resource?.max ?? 0); }; const SelectResources = ({ environment, freeCompute, token }: SelectResourcesProps) => { @@ -109,41 +112,41 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro tokenAddress: token?.address ?? '', }); - const minAllowedCpuCores = cpu?.min ?? 1; - const minAllowedDiskSpace = disk?.min ?? 0; - const minAllowedJobDurationSeconds = minJobDurationSeconds ?? 0; - const minAllowedRam = ram?.min ?? 0; + // Environments expose a single GPU type. The environment is split into equal parts, + // one part per GPU unit: picking N GPUs grants N proportional shares of CPU/RAM/disk. + const gpuRes = gpus[0]; + const hasGpu = !!gpuRes; + if (gpus.length > 1) { + // The UI only models one GPU type; surface a warning if a node ever advertises several so the + // dropped types (and the resulting wrong cost) don't go unnoticed. + console.warn(`Environment exposes ${gpus.length} GPU types; only "${gpuRes.id}" is selectable.`); + } + + // Number of equal parts the environment is divided into (its full GPU capacity). + // No-GPU environments behave as a single, whole-environment unit. + const totalUnits = hasGpu ? Math.max(1, capacityOf(gpuRes)) : 1; + const availableGpuUnits = hasGpu ? (gpusAvailable[gpuRes.id] ?? 0) : 1; + const maxSelectableUnits = Math.min(totalUnits, Math.max(0, availableGpuUnits)); + const gpuExhausted = hasGpu && maxSelectableUnits < 1; + + // Per-unit (per-GPU) share of each resource, derived from full env capacity / total units. + const perUnitCpu = capacityOf(cpu) / totalUnits; + const perUnitRam = capacityOf(ram) / totalUnits; + const perUnitDisk = capacityOf(disk) / totalUnits; - const maxAllowedCpuCores = cpu ? cpuAvailable : minAllowedCpuCores; - const maxAllowedDiskSpace = disk ? diskAvailable : minAllowedDiskSpace; + const minAllowedJobDurationSeconds = minJobDurationSeconds ?? 0; const maxAllowedJobDurationSeconds = maxJobDurationSeconds ?? 0; - const maxAllowedRam = ram ? ramAvailable : minAllowedRam; - - const cpuExhausted = !!cpu && cpuAvailable < minAllowedCpuCores; - const ramExhausted = !!ram && ramAvailable < minAllowedRam; - const diskExhausted = !!disk && diskAvailable < minAllowedDiskSpace; - - const cpuSliderMax = Math.max(minAllowedCpuCores, cpuAvailable); - const ramSliderMax = Math.max(minAllowedRam, ramAvailable); - - const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); - const selectedCpu = selectedResources?.cpuCores; - const selectedDisk = selectedResources?.diskSpace; - const selectedRam = selectedResources?.ram; - const selectedGpus = selectedResources?.gpus.map((gpu) => gpu.id); + const selectedGpuCount = selectedResources?.gpuCount; const selectedMaxJobDurationSeconds = selectedResources?.maxJobDurationSeconds; + const initialGpuCount = hasGpu ? clamp(selectedGpuCount || 1, 1, Math.max(1, maxSelectableUnits)) : 1; + const formik = useFormik({ enableReinitialize: true, initialValues: { - // Clamp prior/hydrated selections into what's currently available so a stale link - // can't pre-fill an unavailable amount. - cpuCores: clamp(selectedCpu ?? minAllowedCpuCores, minAllowedCpuCores, cpuSliderMax), - diskSpace: clamp(selectedDisk ?? minAllowedDiskSpace, minAllowedDiskSpace, maxAllowedDiskSpace), - gpus: (selectedGpus ?? []).filter((id) => (gpusAvailable[id] ?? 0) > 0), + gpuCount: initialGpuCount, maxJobDurationSeconds: selectedMaxJobDurationSeconds ?? minAllowedJobDurationSeconds, - ram: clamp(selectedRam ?? minAllowedRam, minAllowedRam, ramSliderMax), }, onSubmit: (values) => { if (!account?.isConnected) { @@ -157,32 +160,31 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro estimatedTotalCost && token?.address ? roundTokenAmount(estimatedTotalCost, token.address, 'up') : 0 ); setSelectedResources({ - cpuCores: values.cpuCores, + cpuCores: derivedCpu, cpuId: cpu?.id ?? 'cpu', - diskSpace: Number(values.diskSpace) || 0, + diskSpace: derivedDisk, diskId: disk?.id ?? 'disk', - gpus: gpus - .filter((gpu) => values.gpus.includes(gpu.id)) - .map((gpu) => ({ id: gpu.id, description: gpu.description })), + gpus: hasGpu ? [{ id: gpuRes.id, description: gpuRes.description }] : [], + gpuCount: hasGpu ? values.gpuCount : 0, maxJobDurationSeconds: values.maxJobDurationSeconds, - ram: values.ram, + ram: derivedRam, ramId: ram?.id ?? 'ram', }); posthog.capture('environment_configured', { - cpuCores: values.cpuCores, - ram: values.ram, - diskSpace: Number(values.diskSpace) || 0, - gpus: values.gpus, + cpuCores: derivedCpu, + ram: derivedRam, + diskSpace: derivedDisk, + gpuCount: hasGpu ? values.gpuCount : 0, maxJobDurationSeconds: values.maxJobDurationSeconds, estimatedTotalCost, freeCompute, }); const query = { ...router.query, - cpu: values.cpuCores, - ram: values.ram, - disk: values.diskSpace, - ...(values.gpus.length > 0 && { gpus: values.gpus }), + cpu: derivedCpu, + ram: derivedRam, + disk: derivedDisk, + ...(hasGpu && { gpus: gpuRes.id, gpuCount: values.gpuCount }), maxJobDuration: values.maxJobDurationSeconds, }; @@ -194,40 +196,42 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }, validateOnMount: true, validationSchema: Yup.object({ - cpuCores: Yup.number() + gpuCount: Yup.number() .required('Required') - .min(minAllowedCpuCores, 'Limits exceeded') - .max(maxAllowedCpuCores, cpuExhausted ? 'Not enough available' : 'Limits exceeded') + .min(1, 'Select at least one unit') + .max(Math.max(1, maxSelectableUnits), gpuExhausted ? 'Not enough available' : 'Limits exceeded') .integer('Invalid format'), - diskSpace: Yup.number() - .required('Required') - .min(minAllowedDiskSpace, 'Limits exceeded') - .max(maxAllowedDiskSpace, diskExhausted ? 'Not enough available' : 'Limits exceeded'), - gpus: Yup.array() - .of(Yup.string()) - .test('gpus-available', 'One or more selected GPUs are no longer available', (value) => - (value ?? []).every((id) => (id ? (gpusAvailable[id] ?? 0) > 0 : true)) - ), maxJobDurationSeconds: Yup.number() .required('Required') .min(minAllowedJobDurationSeconds, 'Limits exceeded') .max(maxAllowedJobDurationSeconds, 'Limits exceeded'), - ram: Yup.number() - .required('Required') - .min(minAllowedRam, 'Limits exceeded') - .max(maxAllowedRam, ramExhausted ? 'Not enough available' : 'Limits exceeded'), }), }); - const resources = useMemo( - () => [ - { id: cpu?.id ?? 'cpu', amount: formik.values.cpuCores }, - { id: disk?.id ?? 'disk', amount: Number(formik.values.diskSpace) || 0 }, - { id: ram?.id ?? 'ram', amount: formik.values.ram }, - ...formik.values.gpus.map((gpuId) => ({ id: gpuId, amount: 1 })), - ], - [cpu?.id, disk?.id, ram?.id, formik.values.cpuCores, formik.values.diskSpace, formik.values.ram, formik.values.gpus] - ); + const unitCount = hasGpu ? formik.values.gpuCount : 1; + + // Derived resource amounts for the chosen number of units, clamped to what's actually available. + const derivedCpu = clamp(Math.round(perUnitCpu * unitCount), cpu?.min ?? 0, Math.max(0, cpuAvailable)); + const derivedRam = clamp(Math.round(perUnitRam * unitCount), ram?.min ?? 0, Math.max(0, ramAvailable)); + const derivedDisk = clamp(Math.round(perUnitDisk * unitCount), disk?.min ?? 0, Math.max(0, diskAvailable)); + + const resourcesExhausted = + gpuExhausted || + (!!cpu && cpuAvailable < (cpu.min ?? 0)) || + (!!ram && ramAvailable < (ram.min ?? 0)) || + (!!disk && diskAvailable < (disk.min ?? 0)); + + const resources = useMemo(() => { + const list = [ + { id: cpu?.id ?? 'cpu', amount: derivedCpu }, + { id: disk?.id ?? 'disk', amount: derivedDisk }, + { id: ram?.id ?? 'ram', amount: derivedRam }, + ]; + if (hasGpu) { + list.push({ id: gpuRes.id, amount: unitCount }); + } + return list; + }, [cpu?.id, disk?.id, ram?.id, gpuRes?.id, hasGpu, derivedCpu, derivedRam, derivedDisk, unitCount]); const estimateCost = useCallback(async () => { setIsLoadingCost(true); @@ -260,29 +264,30 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }; }, [estimateCost]); - const selectAllGpus = () => { - formik.setFieldValue( - 'gpus', - gpus.filter((gpu) => (gpusAvailable[gpu.id] ?? 0) > 0).map((gpu) => gpu.id) - ); - }; - - const setMaxDiskSpace = () => { - formik.setFieldValue('diskSpace', maxAllowedDiskSpace); + const setMaxGpus = () => { + formik.setFieldValue('gpuCount', Math.max(1, maxSelectableUnits)); }; const setMaxJobDuration = () => { formik.setFieldValue('maxJobDurationSeconds', maxAllowedJobDurationSeconds); }; - const handleDiskSpaceChange = (e: React.ChangeEvent) => { - if (e.target.value === '') { - formik.setFieldValue('diskSpace', ''); - return; - } - const num = Number(e.target.value); - formik.setFieldValue('diskSpace', Math.max(0, num)); - }; + const renderDerivedResource = (label: React.ReactNode, value: string, fee: React.ReactNode) => ( + +
{label}
+
{value}
+
{fee}
+
+ ); const renderCostCard = () => { const renderCostEstimation = () => { @@ -364,120 +369,98 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro ); }; + const gpuFeeHint = hasGpu + ? freeCompute + ? 'Free' + : `${gpuFees[gpuRes.id] ?? 0} ${token?.symbol}/unit` + : ''; + return (

Select resources

- - Set max - - } - errorText={formik.touched.diskSpace && formik.errors.diskSpace ? formik.errors.diskSpace : undefined} - hint={freeCompute ? 'Free' : `${diskFee ?? 0} ${token?.symbol}/GB`} - label={ -
- Disk space{' '} - - - -
- } - max={maxAllowedDiskSpace} - min={0} - name="diskSpace" - onBlur={formik.handleBlur} - onChange={handleDiskSpaceChange} - startAdornment="GB" - topRight={`${minAllowedDiskSpace} - ${maxAllowedDiskSpace} available`} - type="number" - value={formik.values.diskSpace} - /> - formik.setFieldValue('maxJobDurationSeconds', seconds)} - onSetMax={setMaxJobDuration} - topRight={`${formatDuration(minAllowedJobDurationSeconds, true)} - ${formatDuration(maxAllowedJobDurationSeconds, true)}`} - value={formik.values.maxJobDurationSeconds} - /> - + {freeCompute ? null : ( {initComputeError ? {renderConnectionErrorCard()} : null} - {!initComputeError && formik.isValid ? {renderCostCard()} : null} + {!initComputeError && formik.isValid && !resourcesExhausted ? {renderCostCard()} : null} )}
@@ -490,7 +473,7 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro > Change environment -
diff --git a/src/components/run-job/summary.tsx b/src/components/run-job/summary.tsx index 1db84578..80bcb321 100644 --- a/src/components/run-job/summary.tsx +++ b/src/components/run-job/summary.tsx @@ -115,7 +115,9 @@ const Summary = ({ const resources: IdeResourceRequest[] = [ ...gpus.map((availableGpu) => ({ id: availableGpu.id, - amount: selectedResources.gpus.find((selectedGpu) => selectedGpu.id === availableGpu.id) ? 1 : 0, + amount: selectedResources.gpus.find((selectedGpu) => selectedGpu.id === availableGpu.id) + ? (selectedResources.gpuCount ?? 1) + : 0, description: availableGpu.description, })), ]; @@ -207,7 +209,9 @@ const Summary = ({
GPU:
{selectedResources.gpus.length - ? selectedResources.gpus.map((gpu) => ) + ? selectedResources.gpus.map((gpu) => ( + + )) : '-'}
CPU cores:
diff --git a/src/context/run-job-context.tsx b/src/context/run-job-context.tsx index 8653bb0a..54745691 100644 --- a/src/context/run-job-context.tsx +++ b/src/context/run-job-context.tsx @@ -22,6 +22,16 @@ export type SelectedToken = { address: string; }; +// gpuCount comes from a user-controllable query string, so guard against NaN/negative values +// (e.g. ?gpuCount=abc) before they propagate into sliders and cost estimates. +const parseGpuCount = (raw: string | null, gpuTypeCount: number): number => { + const parsed = raw ? Number(raw) : NaN; + if (Number.isFinite(parsed) && parsed > 0) { + return Math.floor(parsed); + } + return gpuTypeCount > 0 ? 1 : 0; +}; + type RunJobContextType = { estimatedTotalCost: number | null; fetchEstimatedCost: ({ @@ -296,11 +306,13 @@ export const RunJobProvider = ({ children }: { children: ReactNode }) => { const qJobDuration = searchParams.get('maxJobDuration'); const queryGpusArray = searchParams.getAll('gpus[]'); const queryGpus = queryGpusArray.length > 0 ? queryGpusArray : searchParams.getAll('gpus'); + const qGpuCount = searchParams.get('gpuCount'); let resources: EnvResourcesSelection = { gpus: queryGpus.map((gpuId) => { const gpuRes = foundEnv.resources?.find((res) => res.type === 'gpu' && res.id === gpuId); return { id: gpuId, description: gpuRes?.description }; }), + gpuCount: parseGpuCount(qGpuCount, queryGpus.length), maxJobDurationSeconds: qJobDuration ? Number(qJobDuration) : queryFree @@ -359,7 +371,7 @@ export const RunJobProvider = ({ children }: { children: ReactNode }) => { ...(resources.diskId && resources.diskSpace ? [{ id: resources.diskId, amount: resources.diskSpace }] : []), - ...resources.gpus.map((gpu) => ({ id: gpu.id, amount: 1 })), + ...resources.gpus.map((gpu) => ({ id: gpu.id, amount: resources.gpuCount ?? 1 })), ], tokenAddress: queryToken, }); diff --git a/src/lib/use-pay-session.ts b/src/lib/use-pay-session.ts new file mode 100644 index 00000000..fc12656c --- /dev/null +++ b/src/lib/use-pay-session.ts @@ -0,0 +1,171 @@ +import { CHAIN_ID } from '@/constants/chains'; +import { getTokenDecimals } from '@/lib/token-symbol'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { useAlchemySendTransaction } from '@/lib/use-alchemy-client'; +import { formatWalletAddress } from '@/utils/formatters'; +import Address from '@oceanprotocol/contracts/addresses/address.json'; +import Escrow from '@oceanprotocol/contracts/artifacts/contracts/escrow/Escrow.sol/Escrow.json'; +import ERC20Template from '@oceanprotocol/contracts/artifacts/contracts/templates/ERC20Template.sol/ERC20Template.json'; +import BigNumber from 'bignumber.js'; +import posthog from 'posthog-js'; +import { useCallback, useState } from 'react'; +import { toast } from 'react-toastify'; +import { encodeFunctionData } from 'viem'; + +export interface PaySessionParams { + tokenAddress: string; + peerId?: string; + spender: string; + // Amount to deposit into escrow. Pass "0" (or omit) when escrow already holds enough. + depositAmount?: string; + maxLockedAmount: string; + maxLockSeconds: string; + maxLockCount: string; +} + +export interface UsePaySessionParams { + onSuccess?: () => void; +} + +export interface UsePaySessionReturn { + isPaying: boolean; + handlePay: (params: PaySessionParams) => Promise; + error?: string; +} + +/** + * Single-step payment: deposits funds into escrow (if needed) and authorizes the spender in one + * action. For smart accounts the approve + deposit + authorize calls are batched into a single + * user confirmation. For EOAs they run sequentially (no batching available until the contract + * exposes a bundle method). + */ +export const usePaySession = ({ onSuccess }: UsePaySessionParams = {}): UsePaySessionReturn => { + const { ocean, user } = useOceanAccount(); + const { sendTransaction } = useAlchemySendTransaction(); + + const [isPaying, setIsPaying] = useState(false); + const [error, setError] = useState(); + const chainId = CHAIN_ID; + + const handlePay = useCallback( + async ({ + tokenAddress, + peerId, + spender, + depositAmount, + maxLockedAmount, + maxLockSeconds, + maxLockCount, + }: PaySessionParams) => { + if (!tokenAddress || !spender) { + setError('Missing required parameters'); + toast.error('Missing required parameters'); + return; + } + + const needsDeposit = new BigNumber(depositAmount ?? 0).gt(0); + + setIsPaying(true); + setError(undefined); + try { + if (user?.type === 'eoa') { + // EOA path: no batching, run the deposit (approve + deposit) then the authorize sequentially. + if (!ocean) throw new Error('Wallet not ready'); + if (needsDeposit) { + const depositTx = await ocean.depositTokensEoa({ tokenAddress, amount: depositAmount! }); + await depositTx.wait(); + posthog.capture('payment_deposit', { tokenAddress, amount: depositAmount }); + } + const authorizeTx = await ocean.authorizeTokensEoa({ + tokenAddress, + spender, + maxLockedAmount, + maxLockSeconds, + maxLockCount, + }); + await authorizeTx.wait(); + posthog.capture('payment_authorize'); + } else { + // Smart account path: batch approve + deposit + authorize into a single confirmation. + const config = Object.values(Address).find((chainConfig: any) => chainConfig.chainId === chainId); + if (!config || !(config as any).Escrow) { + throw new Error('No escrow found for chainId'); + } + const escrowAddress = (config as any).Escrow as `0x${string}`; + + const tokenDecimals = await getTokenDecimals(tokenAddress); + const toUnits = (amount: string) => + new BigNumber(amount).multipliedBy(new BigNumber(10).pow(Number(tokenDecimals))).toFixed(0); + + // Calls run in order within a single atomic user-operation: approve must precede deposit so + // deposit observes the new allowance. The approve grants exactly the deposit amount (no + // approveMax), so USDT-style "reset to 0 first" tokens with a stale allowance would revert; + // the supported tokens (USDC/COMPY) don't enforce that. Until the Escrow exposes a bundle + // method, this batch is the single-confirmation equivalent of the old two-step flow. + const calls: { to: `0x${string}`; data: `0x${string}` }[] = []; + + if (needsDeposit) { + const normalizedDeposit = toUnits(depositAmount!); + calls.push({ + to: tokenAddress as `0x${string}`, + data: encodeFunctionData({ + abi: ERC20Template.abi, + functionName: 'approve', + args: [escrowAddress, BigInt(normalizedDeposit)], + }) as `0x${string}`, + }); + calls.push({ + to: escrowAddress, + data: encodeFunctionData({ + abi: Escrow.abi, + functionName: 'deposit', + args: [tokenAddress, BigInt(normalizedDeposit)], + }) as `0x${string}`, + }); + } + + calls.push({ + to: escrowAddress, + data: encodeFunctionData({ + abi: Escrow.abi, + functionName: 'authorize', + args: [ + tokenAddress, + spender, + BigInt(toUnits(maxLockedAmount)), + BigInt(maxLockSeconds), + BigInt(maxLockCount), + ], + }) as `0x${string}`, + }); + + await sendTransaction(calls); + + if (needsDeposit) { + posthog.capture('payment_deposit', { tokenAddress, amount: depositAmount }); + } + posthog.capture('payment_authorize'); + } + + toast.success(paySuccessMessage(spender, peerId)); + onSuccess?.(); + } catch (err) { + console.error('Pay session error:', err); + setError(err instanceof Error ? err.message : 'Payment failed'); + toast.error('Payment failed. See console for details.'); + } finally { + setIsPaying(false); + } + }, + [user?.type, ocean, sendTransaction, chainId, onSuccess] + ); + + return { isPaying, handlePay, error }; +}; + +const paySuccessMessage = (consumerAddress: string, peerId?: string) => { + const target = peerId + ? `node ${formatWalletAddress(peerId)} (consumer ${formatWalletAddress(consumerAddress)})` + : `consumer ${formatWalletAddress(consumerAddress)}`; + return `Payment authorized for ${target}. Your session can start shortly.`; +}; diff --git a/src/types/environments.ts b/src/types/environments.ts index 52e6e4b4..8f821008 100644 --- a/src/types/environments.ts +++ b/src/types/environments.ts @@ -80,6 +80,8 @@ export type EnvResourcesSelection = { diskSpace?: number; diskId?: string; gpus: { id: string; description?: string }[]; + // Number of GPU units selected. Drives the proportional CPU/RAM/disk split. + gpuCount?: number; maxJobDurationSeconds: number; ram?: number; ramId?: string; From 6cf15c7392cb14ffed9b73aae88f6f6a66d30b51 Mon Sep 17 00:00:00 2001 From: Denis <61563365+dnsi0@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:24:48 +0300 Subject: [PATCH 02/10] fix gpu selection --- src/components/run-job/select-resources.tsx | 85 +++++++++++++-------- src/components/run-job/summary.tsx | 25 +++--- src/context/run-job-context.tsx | 44 +++++++++-- src/types/environments.ts | 11 ++- src/utils/resources.ts | 39 ++++++++++ 5 files changed, 157 insertions(+), 47 deletions(-) diff --git a/src/components/run-job/select-resources.tsx b/src/components/run-job/select-resources.tsx index 6b5be8eb..d1916fb2 100644 --- a/src/components/run-job/select-resources.tsx +++ b/src/components/run-job/select-resources.tsx @@ -8,9 +8,10 @@ import config from '@/config'; import { SelectedToken, useRunJobContext } from '@/context/run-job-context'; import { useP2P } from '@/contexts/P2PContext'; import { useOceanAccount } from '@/lib/use-ocean-account'; -import { ComputeEnvironment, ComputeResource } from '@/types/environments'; +import { ComputeEnvironment } from '@/types/environments'; import { DURATION_UNIT_OPTIONS } from '@/utils/duration'; import { formatDuration, formatTokenAmount, roundTokenAmount } from '@/utils/formatters'; +import { capacityOf, distributeGpus } from '@/utils/resources'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { CircularProgress, Collapse, Tooltip } from '@mui/material'; import { usePrivy } from '@privy-io/react-auth'; @@ -35,12 +36,6 @@ type ResourcesFormValues = { const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); -// Full env capacity for a resource. Prefer `total` (whole-env capacity); fall back to `max`. -const capacityOf = (resource?: ComputeResource) => { - const total = resource?.total ?? 0; - return total > 0 ? total : (resource?.max ?? 0); -}; - const SelectResources = ({ environment, freeCompute, token }: SelectResourcesProps) => { const { login } = usePrivy(); const router = useRouter(); @@ -112,20 +107,17 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro tokenAddress: token?.address ?? '', }); - // Environments expose a single GPU type. The environment is split into equal parts, - // one part per GPU unit: picking N GPUs grants N proportional shares of CPU/RAM/disk. - const gpuRes = gpus[0]; - const hasGpu = !!gpuRes; - if (gpus.length > 1) { - // The UI only models one GPU type; surface a warning if a node ever advertises several so the - // dropped types (and the resulting wrong cost) don't go unnoticed. - console.warn(`Environment exposes ${gpus.length} GPU types; only "${gpuRes.id}" is selectable.`); - } - - // Number of equal parts the environment is divided into (its full GPU capacity). + // The environment is split into equal GPU-sized parts: picking N GPUs grants N proportional + // shares of CPU/RAM/disk. Nodes with multiple physical GPUs of the same model expose one + // resource entry per GPU (each total: 1), so we sum capacity across all GPU entries. + const hasGpu = gpus.length > 0; + + // Total physical GPU slots across all GPU resource entries the node advertises. // No-GPU environments behave as a single, whole-environment unit. - const totalUnits = hasGpu ? Math.max(1, capacityOf(gpuRes)) : 1; - const availableGpuUnits = hasGpu ? (gpusAvailable[gpuRes.id] ?? 0) : 1; + const totalUnits = hasGpu ? Math.max(1, gpus.reduce((sum, g) => sum + capacityOf(g), 0)) : 1; + const availableGpuUnits = hasGpu + ? gpus.reduce((sum, g) => sum + (gpusAvailable[g.id] ?? 0), 0) + : 1; const maxSelectableUnits = Math.min(totalUnits, Math.max(0, availableGpuUnits)); const gpuExhausted = hasGpu && maxSelectableUnits < 1; @@ -164,7 +156,7 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro cpuId: cpu?.id ?? 'cpu', diskSpace: derivedDisk, diskId: disk?.id ?? 'disk', - gpus: hasGpu ? [{ id: gpuRes.id, description: gpuRes.description }] : [], + gpus: selectedGpuEntries, gpuCount: hasGpu ? values.gpuCount : 0, maxJobDurationSeconds: values.maxJobDurationSeconds, ram: derivedRam, @@ -184,7 +176,12 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro cpu: derivedCpu, ram: derivedRam, disk: derivedDisk, - ...(hasGpu && { gpus: gpuRes.id, gpuCount: values.gpuCount }), + // Encode the per-entry amount alongside the id (`id:amount`) so the exact GPU split the + // user picked round-trips losslessly through the URL instead of being re-guessed. + ...(hasGpu && { + gpus: selectedGpuEntries.map((g) => `${g.id}:${g.amount}`), + gpuCount: values.gpuCount, + }), maxJobDuration: values.maxJobDurationSeconds, }; @@ -210,6 +207,22 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro const unitCount = hasGpu ? formik.values.gpuCount : 1; + // GPU resource entries allocated to the current unit-count selection, distributed across + // the available physical GPUs in declared order. + const selectedGpuEntries = useMemo( + () => (hasGpu ? distributeGpus(unitCount, gpus, gpusAvailable) : []), + [hasGpu, unitCount, gpus, gpusAvailable] + ); + + // A live availability refetch can drop maxSelectableUnits below the current selection (other + // jobs grabbed GPUs). Clamp the slider down so the user can never request more than is free. + useEffect(() => { + if (hasGpu && formik.values.gpuCount > maxSelectableUnits) { + formik.setFieldValue('gpuCount', Math.max(1, maxSelectableUnits)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasGpu, maxSelectableUnits]); + // Derived resource amounts for the chosen number of units, clamped to what's actually available. const derivedCpu = clamp(Math.round(perUnitCpu * unitCount), cpu?.min ?? 0, Math.max(0, cpuAvailable)); const derivedRam = clamp(Math.round(perUnitRam * unitCount), ram?.min ?? 0, Math.max(0, ramAvailable)); @@ -227,11 +240,11 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro { id: disk?.id ?? 'disk', amount: derivedDisk }, { id: ram?.id ?? 'ram', amount: derivedRam }, ]; - if (hasGpu) { - list.push({ id: gpuRes.id, amount: unitCount }); + for (const gpu of selectedGpuEntries) { + list.push({ id: gpu.id, amount: gpu.amount }); } return list; - }, [cpu?.id, disk?.id, ram?.id, gpuRes?.id, hasGpu, derivedCpu, derivedRam, derivedDisk, unitCount]); + }, [cpu?.id, disk?.id, ram?.id, selectedGpuEntries, derivedCpu, derivedRam, derivedDisk]); const estimateCost = useCallback(async () => { setIsLoadingCost(true); @@ -369,10 +382,22 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro ); }; - const gpuFeeHint = hasGpu - ? freeCompute - ? 'Free' - : `${gpuFees[gpuRes.id] ?? 0} ${token?.symbol}/unit` + // Show a single rate when every GPU costs the same, otherwise a range, so heterogeneous + // environments don't advertise just the first GPU's price. + const gpuFeeHint = (() => { + if (!hasGpu) return ''; + if (freeCompute) return 'Free'; + const fees = gpus.map((g) => gpuFees[g.id] ?? 0); + const min = Math.min(...fees); + const max = Math.max(...fees); + const range = min === max ? `${min}` : `${min}–${max}`; + return `${range} ${token?.symbol}/unit`; + })(); + + const gpuDisplayLabel = hasGpu + ? gpus.every((g) => g.description === gpus[0].description) + ? gpus[0].description + : gpus.map((g) => g.description ?? g.id).join(' / ') : ''; return ( @@ -382,7 +407,7 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro {hasGpu ? ( <>
- +
{gpuFeeHint}
CPU cores:
From 868f4e84f1ce4e2fcd50391a325d8b016894626e Mon Sep 17 00:00:00 2001 From: Denis <61563365+dnsi0@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:35:37 +0300 Subject: [PATCH 04/10] adapt GPU selector --- src/components/run-job/select-resources.tsx | 165 ++++++++------------ 1 file changed, 68 insertions(+), 97 deletions(-) diff --git a/src/components/run-job/select-resources.tsx b/src/components/run-job/select-resources.tsx index d1916fb2..dc45a34e 100644 --- a/src/components/run-job/select-resources.tsx +++ b/src/components/run-job/select-resources.tsx @@ -3,7 +3,7 @@ import Card from '@/components/card/card'; import GpuLabel from '@/components/gpu-label/gpu-label'; import useEnvResources from '@/components/hooks/use-env-resources'; import DurationInput from '@/components/input/duration-input'; -import Slider from '@/components/slider/slider'; +import Select from '@/components/input/select'; import config from '@/config'; import { SelectedToken, useRunJobContext } from '@/context/run-job-context'; import { useP2P } from '@/contexts/P2PContext'; @@ -11,7 +11,7 @@ import { useOceanAccount } from '@/lib/use-ocean-account'; import { ComputeEnvironment } from '@/types/environments'; import { DURATION_UNIT_OPTIONS } from '@/utils/duration'; import { formatDuration, formatTokenAmount, roundTokenAmount } from '@/utils/formatters'; -import { capacityOf, distributeGpus } from '@/utils/resources'; +import { capacityOf } from '@/utils/resources'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { CircularProgress, Collapse, Tooltip } from '@mui/material'; import { usePrivy } from '@privy-io/react-auth'; @@ -30,7 +30,7 @@ type SelectResourcesProps = { }; type ResourcesFormValues = { - gpuCount: number; + gpus: string[]; maxJobDurationSeconds: number; }; @@ -107,19 +107,16 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro tokenAddress: token?.address ?? '', }); - // The environment is split into equal GPU-sized parts: picking N GPUs grants N proportional - // shares of CPU/RAM/disk. Nodes with multiple physical GPUs of the same model expose one - // resource entry per GPU (each total: 1), so we sum capacity across all GPU entries. + // The environment is split into equal GPU-sized parts: each selected GPU grants one proportional + // share of CPU/RAM/disk. Nodes expose one resource entry per physical GPU (each total: 1), so the + // number of GPU units equals the number of selected GPU entries. const hasGpu = gpus.length > 0; // Total physical GPU slots across all GPU resource entries the node advertises. // No-GPU environments behave as a single, whole-environment unit. const totalUnits = hasGpu ? Math.max(1, gpus.reduce((sum, g) => sum + capacityOf(g), 0)) : 1; - const availableGpuUnits = hasGpu - ? gpus.reduce((sum, g) => sum + (gpusAvailable[g.id] ?? 0), 0) - : 1; - const maxSelectableUnits = Math.min(totalUnits, Math.max(0, availableGpuUnits)); - const gpuExhausted = hasGpu && maxSelectableUnits < 1; + // Every advertised GPU is currently in use elsewhere — nothing left to pick. + const gpuExhausted = hasGpu && gpus.every((g) => (gpusAvailable[g.id] ?? 0) <= 0); // Per-unit (per-GPU) share of each resource, derived from full env capacity / total units. const perUnitCpu = capacityOf(cpu) / totalUnits; @@ -129,15 +126,14 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro const minAllowedJobDurationSeconds = minJobDurationSeconds ?? 0; const maxAllowedJobDurationSeconds = maxJobDurationSeconds ?? 0; - const selectedGpuCount = selectedResources?.gpuCount; + const selectedGpuIds = selectedResources?.gpus?.map((g) => g.id); const selectedMaxJobDurationSeconds = selectedResources?.maxJobDurationSeconds; - const initialGpuCount = hasGpu ? clamp(selectedGpuCount || 1, 1, Math.max(1, maxSelectableUnits)) : 1; - const formik = useFormik({ enableReinitialize: true, initialValues: { - gpuCount: initialGpuCount, + // Keep only still-available GPUs so a stale link can't pre-select an unavailable one. + gpus: (selectedGpuIds ?? []).filter((id) => (gpusAvailable[id] ?? 0) > 0), maxJobDurationSeconds: selectedMaxJobDurationSeconds ?? minAllowedJobDurationSeconds, }, onSubmit: (values) => { @@ -157,7 +153,7 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro diskSpace: derivedDisk, diskId: disk?.id ?? 'disk', gpus: selectedGpuEntries, - gpuCount: hasGpu ? values.gpuCount : 0, + gpuCount: selectedGpuEntries.length, maxJobDurationSeconds: values.maxJobDurationSeconds, ram: derivedRam, ramId: ram?.id ?? 'ram', @@ -166,7 +162,7 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro cpuCores: derivedCpu, ram: derivedRam, diskSpace: derivedDisk, - gpuCount: hasGpu ? values.gpuCount : 0, + gpuCount: selectedGpuEntries.length, maxJobDurationSeconds: values.maxJobDurationSeconds, estimatedTotalCost, freeCompute, @@ -176,11 +172,11 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro cpu: derivedCpu, ram: derivedRam, disk: derivedDisk, - // Encode the per-entry amount alongside the id (`id:amount`) so the exact GPU split the - // user picked round-trips losslessly through the URL instead of being re-guessed. - ...(hasGpu && { - gpus: selectedGpuEntries.map((g) => `${g.id}:${g.amount}`), - gpuCount: values.gpuCount, + // Each selected GPU is one unit; encode `id:1` so the selection round-trips losslessly and + // the hydration clamps it to current availability. + ...(values.gpus.length > 0 && { + gpus: values.gpus.map((id) => `${id}:1`), + gpuCount: values.gpus.length, }), maxJobDuration: values.maxJobDurationSeconds, }; @@ -193,11 +189,12 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }, validateOnMount: true, validationSchema: Yup.object({ - gpuCount: Yup.number() - .required('Required') - .min(1, 'Select at least one unit') - .max(Math.max(1, maxSelectableUnits), gpuExhausted ? 'Not enough available' : 'Limits exceeded') - .integer('Invalid format'), + gpus: Yup.array() + .of(Yup.string()) + // GPUs are optional — a job with none gets the minimum CPU/RAM/disk slice. + .test('gpus-available', 'One or more selected GPUs are no longer available', (value) => + (value ?? []).every((id) => (id ? (gpusAvailable[id] ?? 0) > 0 : true)) + ), maxJobDurationSeconds: Yup.number() .required('Required') .min(minAllowedJobDurationSeconds, 'Limits exceeded') @@ -205,24 +202,18 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }), }); - const unitCount = hasGpu ? formik.values.gpuCount : 1; + // Each selected GPU is one unit; no-GPU envs run as a single whole-environment unit. + const unitCount = hasGpu ? formik.values.gpus.length : 1; - // GPU resource entries allocated to the current unit-count selection, distributed across - // the available physical GPUs in declared order. + // The GPU resource entries the user picked, one unit each. const selectedGpuEntries = useMemo( - () => (hasGpu ? distributeGpus(unitCount, gpus, gpusAvailable) : []), - [hasGpu, unitCount, gpus, gpusAvailable] + () => + gpus + .filter((g) => formik.values.gpus.includes(g.id)) + .map((g) => ({ id: g.id, description: g.description, amount: 1 })), + [gpus, formik.values.gpus] ); - // A live availability refetch can drop maxSelectableUnits below the current selection (other - // jobs grabbed GPUs). Clamp the slider down so the user can never request more than is free. - useEffect(() => { - if (hasGpu && formik.values.gpuCount > maxSelectableUnits) { - formik.setFieldValue('gpuCount', Math.max(1, maxSelectableUnits)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasGpu, maxSelectableUnits]); - // Derived resource amounts for the chosen number of units, clamped to what's actually available. const derivedCpu = clamp(Math.round(perUnitCpu * unitCount), cpu?.min ?? 0, Math.max(0, cpuAvailable)); const derivedRam = clamp(Math.round(perUnitRam * unitCount), ram?.min ?? 0, Math.max(0, ramAvailable)); @@ -277,14 +268,17 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }; }, [estimateCost]); - const setMaxGpus = () => { - formik.setFieldValue('gpuCount', Math.max(1, maxSelectableUnits)); - }; - const setMaxJobDuration = () => { formik.setFieldValue('maxJobDurationSeconds', maxAllowedJobDurationSeconds); }; + const selectAllGpus = () => { + formik.setFieldValue( + 'gpus', + gpus.filter((g) => (gpusAvailable[g.id] ?? 0) > 0).map((g) => g.id) + ); + }; + const renderDerivedResource = (label: React.ReactNode, value: string, fee: React.ReactNode) => ( { - if (!hasGpu) return ''; - if (freeCompute) return 'Free'; - const fees = gpus.map((g) => gpuFees[g.id] ?? 0); - const min = Math.min(...fees); - const max = Math.max(...fees); - const range = min === max ? `${min}` : `${min}–${max}`; - return `${range} ${token?.symbol}/unit`; - })(); - - const gpuDisplayLabel = hasGpu - ? gpus.every((g) => g.description === gpus[0].description) - ? gpus[0].description - : gpus.map((g) => g.description ?? g.id).join(' / ') - : ''; - return (

Select resources

{hasGpu ? ( - <> -
- -
- {gpuFeeHint} - -
-
- (value === 1 ? `${value} unit` : `${value} units`)} - /> - + + } @@ -422,7 +431,11 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro `${derivedCpu} ${derivedCpu === 1 ? 'core' : 'cores'}`, freeCompute ? 'Free' : `${cpuFee ?? 0} ${token?.symbol}/core` )} - {renderDerivedResource('RAM', `${derivedRam} GB`, freeCompute ? 'Free' : `${ramFee ?? 0} ${token?.symbol}/GB`)} + {renderDerivedResource( + 'RAM', + `${derivedRam} GB`, + freeCompute ? 'Free' : `${ramFee ?? 0} ${token?.symbol}/GB` + )} {renderDerivedResource(
Disk space{' '} @@ -456,7 +469,9 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro {freeCompute ? null : ( {initComputeError ? {renderConnectionErrorCard()} : null} - {!initComputeError && formik.isValid && !resourcesExhausted ? {renderCostCard()} : null} + {!initComputeError && formik.isValid && !resourcesExhausted ? ( + {renderCostCard()} + ) : null} )}
diff --git a/src/utils/resources.ts b/src/utils/resources.ts index b09bdfde..344137da 100644 --- a/src/utils/resources.ts +++ b/src/utils/resources.ts @@ -12,10 +12,6 @@ export const getAvailableAmount = (resource?: Pick): number => { const total = resource?.total ?? 0; return total > 0 ? total : (resource?.max ?? 0); From 3687e67730db4b36f642d69a335af7594408b7dc Mon Sep 17 00:00:00 2001 From: Denis <61563365+dnsi0@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:38:59 +0300 Subject: [PATCH 07/10] remove unnecessary implementation --- src/context/run-job-context.tsx | 47 ++++++++------------------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/src/context/run-job-context.tsx b/src/context/run-job-context.tsx index 986f19b2..a12b57f0 100644 --- a/src/context/run-job-context.tsx +++ b/src/context/run-job-context.tsx @@ -14,7 +14,7 @@ import { SelectedGpu, } from '@/types/environments'; import { roundTokenAmount } from '@/utils/formatters'; -import { distributeGpus, getAvailableAmount } from '@/utils/resources'; +import { getAvailableAmount } from '@/utils/resources'; import axios from 'axios'; import BigNumber from 'bignumber.js'; import { useSearchParams } from 'next/navigation'; @@ -25,16 +25,6 @@ export type SelectedToken = { address: string; }; -// gpuCount comes from a user-controllable query string, so guard against NaN/negative values -// (e.g. ?gpuCount=abc) before they propagate into sliders and cost estimates. -const parseGpuCount = (raw: string | null, gpuTypeCount: number): number => { - const parsed = raw ? Number(raw) : NaN; - if (Number.isFinite(parsed) && parsed > 0) { - return Math.floor(parsed); - } - return gpuTypeCount > 0 ? 1 : 0; -}; - // Clamp a requested GPU amount to [0, available], so a stale or hand-crafted URL can never // allocate more units of a GPU than the node currently has free. const clampToAvailable = (requested: number, gpuRes?: ComputeResource): number => { @@ -316,34 +306,19 @@ export const RunJobProvider = ({ children }: { children: ReactNode }) => { const qJobDuration = searchParams.get('maxJobDuration'); const queryGpusArray = searchParams.getAll('gpus[]'); const queryGpus = queryGpusArray.length > 0 ? queryGpusArray : searchParams.getAll('gpus'); - const qGpuCount = searchParams.get('gpuCount'); - const parsedGpuCount = parseGpuCount(qGpuCount, queryGpus.length); - // Each gpu query entry is `id` (legacy) or `id:amount` (lossless). Parse the explicit - // amount when present, then clamp every amount to what's actually available so a stale or - // hand-crafted URL can never request more GPUs than the node currently has free. const gpuResources = (foundEnv.resources ?? []).filter((res) => res.type === 'gpu' || res.id === 'gpu'); const findGpuRes = (id: string) => gpuResources.find((res) => res.id === id); - const hasExplicitAmounts = queryGpus.some((raw) => raw.includes(':')); - let hydratedGpus: SelectedGpu[]; - if (hasExplicitAmounts) { - hydratedGpus = queryGpus - .map((raw) => { - const [id, amountStr] = raw.split(':'); - const gpuRes = findGpuRes(id); - const requested = Number(amountStr); - const amount = clampToAvailable(requested, gpuRes); - return { id, description: gpuRes?.description, amount }; - }) - .filter((gpu) => gpu.amount > 0); - } else { - // Legacy URL with bare ids: distribute the total count by availability, same basis the - // selection page uses, so both sides agree on the per-entry split. - const orderedGpus = queryGpus.map(findGpuRes).filter((res): res is ComputeResource => !!res); - hydratedGpus = distributeGpus(parsedGpuCount, orderedGpus); - } - const gpuCount = hydratedGpus.reduce((sum, gpu) => sum + gpu.amount, 0); + const resolvedGpus: SelectedGpu[] = queryGpus + .map((raw) => { + const [id, amountStr] = raw.split(':'); + const gpuRes = findGpuRes(id); + const amount = clampToAvailable(Number(amountStr), gpuRes); + return { id, description: gpuRes?.description, amount }; + }) + .filter((gpu) => gpu.amount > 0); + const gpuCount = resolvedGpus.reduce((sum, gpu) => sum + gpu.amount, 0); let resources: EnvResourcesSelection = { - gpus: hydratedGpus, + gpus: resolvedGpus, gpuCount, maxJobDurationSeconds: qJobDuration ? Number(qJobDuration) From dbe8382ac5841a7f26ff76de0692f8ba2a15f893 Mon Sep 17 00:00:00 2001 From: Denis <61563365+dnsi0@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:53:17 +0300 Subject: [PATCH 08/10] cleanup --- src/context/run-job-context.tsx | 18 +++++++++++------- src/utils/resources.ts | 30 ------------------------------ 2 files changed, 11 insertions(+), 37 deletions(-) diff --git a/src/context/run-job-context.tsx b/src/context/run-job-context.tsx index a12b57f0..3827fe62 100644 --- a/src/context/run-job-context.tsx +++ b/src/context/run-job-context.tsx @@ -25,8 +25,7 @@ export type SelectedToken = { address: string; }; -// Clamp a requested GPU amount to [0, available], so a stale or hand-crafted URL can never -// allocate more units of a GPU than the node currently has free. +// Clamp a requested GPU amount to [0, available] so a hand-crafted URL can't over-allocate. const clampToAvailable = (requested: number, gpuRes?: ComputeResource): number => { const sane = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 0; return Math.min(sane, getAvailableAmount(gpuRes)); @@ -308,12 +307,17 @@ export const RunJobProvider = ({ children }: { children: ReactNode }) => { const queryGpus = queryGpusArray.length > 0 ? queryGpusArray : searchParams.getAll('gpus'); const gpuResources = (foundEnv.resources ?? []).filter((res) => res.type === 'gpu' || res.id === 'gpu'); const findGpuRes = (id: string) => gpuResources.find((res) => res.id === id); - const resolvedGpus: SelectedGpu[] = queryGpus - .map((raw) => { - const [id, amountStr] = raw.split(':'); + const requestedById = new Map(); + for (const raw of queryGpus) { + const [id, amountStr] = raw.split(':'); + const requested = Number(amountStr); + const sane = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 0; + requestedById.set(id, (requestedById.get(id) ?? 0) + sane); + } + const resolvedGpus: SelectedGpu[] = [...requestedById.entries()] + .map(([id, requested]) => { const gpuRes = findGpuRes(id); - const amount = clampToAvailable(Number(amountStr), gpuRes); - return { id, description: gpuRes?.description, amount }; + return { id, description: gpuRes?.description, amount: clampToAvailable(requested, gpuRes) }; }) .filter((gpu) => gpu.amount > 0); const gpuCount = resolvedGpus.reduce((sum, gpu) => sum + gpu.amount, 0); diff --git a/src/utils/resources.ts b/src/utils/resources.ts index 344137da..fe50f493 100644 --- a/src/utils/resources.ts +++ b/src/utils/resources.ts @@ -16,33 +16,3 @@ export const capacityOf = (resource?: Pick): n const total = resource?.total ?? 0; return total > 0 ? total : (resource?.max ?? 0); }; - -export type GpuAllocation = { id: string; description?: string; amount: number }; - -/** - * Distribute a total GPU unit count across the given GPU resource entries, filling each entry up - * to its currently-available capacity (max - inUse) before moving to the next, in declared order. - * Never allocates more of an entry than is available, so the result can always be satisfied by the - * node. Nodes with multiple physical GPUs of the same model expose one resource entry per GPU - * (each total: 1), so this yields an even split for homogeneous boxes and a best-effort fill for - * heterogeneous ones (the most a "pick N units" UX can do without per-type selection). - */ -export const distributeGpus = ( - total: number, - gpus: Pick[], - available?: Record -): GpuAllocation[] => { - if (total <= 0) return []; - const result: GpuAllocation[] = []; - let remaining = total; - for (const gpu of gpus) { - if (remaining <= 0) break; - const capacity = available ? (available[gpu.id] ?? 0) : getAvailableAmount(gpu); - const take = Math.min(remaining, Math.max(0, capacity)); - if (take > 0) { - result.push({ id: gpu.id, description: gpu.description, amount: take }); - remaining -= take; - } - } - return result; -}; From 813cc652094ced41f6fe5b6ac66b4d04d4016e1d Mon Sep 17 00:00:00 2001 From: Denis <61563365+dnsi0@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:30:48 +0300 Subject: [PATCH 09/10] update select gpu --- .../run-job/select-resources.module.css | 71 ++++++++++++++ src/components/run-job/select-resources.tsx | 98 +++++++++++++------ 2 files changed, 141 insertions(+), 28 deletions(-) diff --git a/src/components/run-job/select-resources.module.css b/src/components/run-job/select-resources.module.css index 2164ec4b..7c5a076b 100644 --- a/src/components/run-job/select-resources.module.css +++ b/src/components/run-job/select-resources.module.css @@ -59,6 +59,77 @@ font-size: 14px; } +.gpuGroups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.gpuGroupsLabel { + color: var(--text-primary); + font-size: 16px; +} + +.gpuGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.gpuGroupTop { + align-items: baseline; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.gpuGroupName { + color: var(--text-primary); + font-size: 15px; +} + +.gpuGroupFee, +.gpuGroupMeta { + color: var(--text-secondary); + font-size: 13px; +} + +.pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pill { + background: var(--background-glass); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-primary); + cursor: pointer; + font-size: 14px; + min-width: 40px; + padding: 6px 14px; + transition: + border-color 0.2s ease, + background 0.2s ease, + color 0.2s ease; +} + +.pill:hover { + border-color: var(--accent1); +} + +.pillSelected { + background: var(--accent1); + border-color: var(--accent1); + color: var(--text-primary-inverse); +} + +.gpuError { + color: var(--error); + font-size: 14px; +} + .wholeEnvNote { color: var(--text-secondary); font-size: 14px; diff --git a/src/components/run-job/select-resources.tsx b/src/components/run-job/select-resources.tsx index 13bae08a..bf6e3479 100644 --- a/src/components/run-job/select-resources.tsx +++ b/src/components/run-job/select-resources.tsx @@ -1,9 +1,7 @@ import Button from '@/components/button/button'; import Card from '@/components/card/card'; -import GpuLabel from '@/components/gpu-label/gpu-label'; import useEnvResources from '@/components/hooks/use-env-resources'; import DurationInput from '@/components/input/duration-input'; -import Select from '@/components/input/select'; import config from '@/config'; import { SelectedToken, useRunJobContext } from '@/context/run-job-context'; import { useP2P } from '@/contexts/P2PContext'; @@ -123,6 +121,26 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro // Every advertised GPU is currently in use elsewhere — nothing left to pick. const gpuExhausted = hasGpu && gpus.every((gpu) => (gpusAvailable[gpu.id] ?? 0) <= 0); + // Physical GPUs that share a description are the same model. Group them so the user picks a count + // per model instead of ticking individual cards. availableIds keeps declared order, so a chosen + // count maps deterministically to concrete resource ids. + const gpuGroups = useMemo(() => { + const byDescription = new Map(); + for (const gpu of gpus) { + const description = gpu.description ?? gpu.id; + let group = byDescription.get(description); + if (!group) { + group = { description, fee: gpuFees[gpu.id], availableIds: [], total: 0 }; + byDescription.set(description, group); + } + group.total += 1; + if ((gpusAvailable[gpu.id] ?? 0) > 0) { + group.availableIds.push(gpu.id); + } + } + return [...byDescription.values()]; + }, [gpus, gpusAvailable, gpuFees]); + // Per-unit (per-GPU) share of each resource, derived from full env capacity / total units. const perUnitCpu = capacityOf(cpu) / totalUnits; const perUnitRam = capacityOf(ram) / totalUnits; @@ -281,6 +299,13 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro ); }; + // Replace one model group's selection with its first `count` available cards, leaving the + // selections of other groups untouched. + const setGroupCount = (availableIds: string[], count: number) => { + const others = formik.values.gpus.filter((id) => !availableIds.includes(id)); + formik.setFieldValue('gpus', [...others, ...availableIds.slice(0, count)]); + }; + const renderDerivedResource = (label: React.ReactNode, value: string, fee: React.ReactNode) => ( Select resources {hasGpu ? ( - + Set max + + } + errorText={formik.touched.diskSpace && formik.errors.diskSpace ? formik.errors.diskSpace : undefined} + hint={freeCompute ? 'Free' : `${diskFee ?? 0} ${token?.symbol}/GB`} + label={ +
+ Disk space{' '} + + + +
+ } + max={maxAllowedDiskSpace} + min={0} + name="diskSpace" + onBlur={formik.handleBlur} + onChange={handleDiskSpaceChange} + startAdornment="GB" + topRight={`${minAllowedDiskSpace} - ${maxAllowedDiskSpace} available`} + type="number" + value={formik.values.diskSpace} + /> +
+ ) : ( +
+ {renderDerivedResource( + 'CPU', + `${derivedCpu} ${derivedCpu === 1 ? 'core' : 'cores'}`, + freeCompute ? 'Free' : `${cpuFee ?? 0} ${token?.symbol}/core` + )} + {renderDerivedResource('RAM', `${derivedRam} GB`, freeCompute ? 'Free' : `${ramFee ?? 0} ${token?.symbol}/GB`)} + {renderDerivedResource( +
+ Disk space{' '} + + + +
, + `${derivedDisk} GB`, + freeCompute ? 'Free' : `${diskFee ?? 0} ${token?.symbol}/GB` + )} +
)} -
- {renderDerivedResource( - 'CPU', - `${derivedCpu} ${derivedCpu === 1 ? 'core' : 'cores'}`, - freeCompute ? 'Free' : `${cpuFee ?? 0} ${token?.symbol}/core` - )} - {renderDerivedResource( - 'RAM', - `${derivedRam} GB`, - freeCompute ? 'Free' : `${ramFee ?? 0} ${token?.symbol}/GB` - )} - {renderDerivedResource( -
- Disk space{' '} - - - -
, - `${derivedDisk} GB`, - freeCompute ? 'Free' : `${diskFee ?? 0} ${token?.symbol}/GB` - )} -
-