diff --git a/src/api/mappers/catalog.ts b/src/api/mappers/catalog.ts index 9c543147..533fec40 100644 --- a/src/api/mappers/catalog.ts +++ b/src/api/mappers/catalog.ts @@ -9,10 +9,15 @@ import { CatalogInstanceStatus, CatalogRegion, InstanceCategory, + RegionScope, } from '../model'; export function mapCatalogRegionsList({ regions }: ApiEndpointResult<'listCatalogRegions'>): CatalogRegion[] { - return regions!.map((region) => ({ + return regions!.map(mapCatalogRegion); +} + +export function mapCatalogRegion(region: Api.CatalogRegion): CatalogRegion { + return { identifier: region.id!, displayName: region.name!, status: lowerCase(region.status!) as CatalogRegion['status'], @@ -20,7 +25,8 @@ export function mapCatalogRegionsList({ regions }: ApiEndpointResult<'listCatalo instances: region.instances, hasVolumes: region.volumes_enabled!, category: region.id!.startsWith('aws-') ? 'aws' : 'koyeb', - })); + scope: region.scope as RegionScope, + }; } export function mapCatalogDatacentersList({ diff --git a/src/api/mock/api-factories.ts b/src/api/mock/api-factories.ts index e86edbd6..e78ea0c1 100644 --- a/src/api/mock/api-factories.ts +++ b/src/api/mock/api-factories.ts @@ -38,6 +38,7 @@ export const createApiRegion = createFactory(() => ({ id: createId(), name: '', status: 'AVAILABLE', + datacenters: [], })); export const createApiDatacenter = createFactory(() => ({ diff --git a/src/api/mock/fixtures.ts b/src/api/mock/fixtures.ts index 7a49a02e..978150e9 100644 --- a/src/api/mock/fixtures.ts +++ b/src/api/mock/fixtures.ts @@ -1,110 +1,122 @@ import { createApiCatalogInstance, createApiDatacenter, createApiRegion } from './api-factories'; +const ecoInstanceIds = [ + 'eco-2xlarge', + 'eco-large', + 'eco-medium', + 'eco-micro', + 'eco-nano', + 'eco-small', + 'eco-xlarge', +]; + +const standardInstanceIds = [ + '2xlarge', + '3xlarge', + '4xlarge', + '5xlarge', + 'large', + 'medium', + 'micro', + 'nano', + 'small', + 'xlarge', +]; + +const gpuInstanceIds = [ + '2-gpu-nvidia-a100', + '4-gpu-nvidia-a100', + '8-gpu-nvidia-a100', + 'gpu-nvidia-a100', + 'gpu-nvidia-rtx-4000-sff-ada', +]; + export const catalogRegionFixtures = [ - createApiRegion({ - id: 'bxl', - name: 'Brussels', - coordinates: ['4.3517', '50.8503'], - status: 'COMING_SOON', - instances: [], - datacenters: [], - }), createApiRegion({ id: 'fra', name: 'Frankfurt', - coordinates: ['8.6821', '50.1109'], - status: 'AVAILABLE', - instances: [ - '2-gpu-nvidia-l4', - '2xlarge', - '3xlarge', - '4xlarge', - '5xlarge', - 'eco-2xlarge', - 'eco-large', - 'eco-medium', - 'eco-micro', - 'eco-nano', - 'eco-small', - 'eco-xlarge', - 'free', - 'gpu-nvidia-l4', - 'gpu-nvidia-rtx-4000-sff-ada', - 'large', - 'medium', - 'micro', - 'nano', - 'small', - 'xlarge', - ], - datacenters: ['fra1'], - }), - createApiRegion({ - id: 'maa', - name: 'Chennai', - coordinates: ['80.237617', '13.067439'], - status: 'COMING_SOON', - instances: [], - datacenters: [], + datacenters: ['fra1', 'fra2', 'fsn1'], + instances: ['free', ...ecoInstanceIds, ...standardInstanceIds, ...gpuInstanceIds], + scope: 'metropolitan', + volumes_enabled: true, }), createApiRegion({ id: 'par', name: 'Paris', - coordinates: ['2.3522', '48.8566'], - status: 'AVAILABLE', - instances: [ - '2xlarge', - '3xlarge', - '4xlarge', - '5xlarge', - 'large', - 'medium', - 'micro', - 'nano', - 'small', - 'xlarge', - ], datacenters: ['par1'], - }), - createApiRegion({ - id: 'sao', - name: 'Sao Paulo', - coordinates: ['-46.625290', '-23.533773'], - status: 'COMING_SOON', - instances: [], - datacenters: [], + instances: [...standardInstanceIds, ...gpuInstanceIds], + scope: 'metropolitan', + volumes_enabled: true, }), createApiRegion({ id: 'sfo', name: 'San Francisco', - coordinates: ['-122.419418', '37.774929'], - status: 'COMING_SOON', - instances: [], - datacenters: [], + datacenters: ['sfo1', 'sfo2'], + instances: standardInstanceIds, + scope: 'metropolitan', + volumes_enabled: false, }), createApiRegion({ id: 'sin', name: 'Singapore', - coordinates: ['103.819839', '1.352083'], - status: 'COMING_SOON', - instances: [], - datacenters: [], + datacenters: ['sin1'], + instances: [...ecoInstanceIds, ...standardInstanceIds], + scope: 'metropolitan', + volumes_enabled: false, }), createApiRegion({ id: 'tyo', name: 'Tokyo', - coordinates: ['139.817413', '35.672855'], - status: 'COMING_SOON', - instances: [], - datacenters: [], + datacenters: ['tyo1'], + instances: standardInstanceIds, + scope: 'metropolitan', + volumes_enabled: false, }), createApiRegion({ id: 'was', name: 'Washington, D.C', - coordinates: ['-77.007507', '38.900497'], - status: 'COMING_SOON', - instances: [], - datacenters: [], + datacenters: ['rdu1', 'was1'], + instances: ['free', ...ecoInstanceIds, ...standardInstanceIds, ...gpuInstanceIds], + scope: 'metropolitan', + volumes_enabled: true, + }), + createApiRegion({ + id: 'na', + name: 'North America', + datacenters: ['rdu1', 'was1'], + scope: 'continental', + volumes_enabled: true, + }), + createApiRegion({ + id: 'sa', + name: 'South America', + scope: 'continental', + volumes_enabled: true, + }), + createApiRegion({ + id: 'nea', + name: 'North-East Asia', + scope: 'continental', + volumes_enabled: true, + }), + createApiRegion({ + id: 'sea', + name: 'South-East Asia', + scope: 'continental', + volumes_enabled: true, + }), + createApiRegion({ + id: 'af', + name: 'Africa', + scope: 'continental', + volumes_enabled: true, + }), + createApiRegion({ + id: 'eu', + name: 'Europe', + datacenters: ['fra1'], + scope: 'continental', + volumes_enabled: true, }), ]; diff --git a/src/api/model.ts b/src/api/model.ts index 17fd59fc..abaab5e0 100644 --- a/src/api/model.ts +++ b/src/api/model.ts @@ -124,9 +124,11 @@ export type CatalogRegion = { instances?: string[]; hasVolumes: boolean; category: RegionCategory; + scope: RegionScope; }; export type RegionCategory = 'koyeb' | 'aws'; +export type RegionScope = 'continental' | 'metropolitan'; export type OneClickApp = { name: string; diff --git a/src/application/instance-quota.ts b/src/application/instance-quota.ts index ecf6513f..eb6cc240 100644 --- a/src/application/instance-quota.ts +++ b/src/application/instance-quota.ts @@ -10,16 +10,27 @@ export function useInstanceQuota(instance: CatalogInstance) { } export function useHasInstanceQuota(instance: CatalogInstance, previousInstance?: CatalogInstance) { - const quota = useInstanceQuota(instance); + const hasQuota = useGetHasInstanceQuota(previousInstance); - return useMemo(() => { - // allow keeping the same instance - if (previousInstance?.identifier === instance.identifier) { - return true; - } + return useMemo(() => hasQuota(instance), [instance, hasQuota]); +} + +export function useGetHasInstanceQuota(previousInstance?: CatalogInstance) { + const getQuota = useGetInstanceQuota(); + + return useCallback( + (instance: CatalogInstance) => { + const quota = getQuota(instance); + + // allow keeping the same instance + if (previousInstance?.identifier === instance.identifier) { + return true; + } - return quota.used < quota.max; - }, [instance, previousInstance, quota]); + return quota.used < quota.max; + }, + [getQuota, previousInstance], + ); } export function useGetInstanceQuota() { diff --git a/src/application/instance-region-availability.ts b/src/application/instance-region-availability.ts index 434162b9..b719aeca 100644 --- a/src/application/instance-region-availability.ts +++ b/src/application/instance-region-availability.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useInstances, useRegion, useRegions } from 'src/api/hooks/catalog'; import { useOrganizationSummary } from 'src/api/hooks/session'; @@ -11,27 +11,48 @@ export type RegionAvailability = [available: true] | [available: false, reason: export type RegionUnavailableReason = 'unavailable' | 'regionNotFound' | 'unavailableForInstance'; -export function useRegionAvailabilities() { +export function useRegionAvailabilities(options?: CheckRegionAvailabilityOptions) { const regions = useRegions(); + const optionsMemo = useDeepCompareMemo(options); return useMemo(() => { return toObject( regions, (region) => region.identifier, - (region) => checkRegionAvailability(region), + (region) => checkRegionAvailability(region, optionsMemo), ); - }, [regions]); + }, [regions, optionsMemo]); +} + +export function useIsRegionAvailable(options?: CheckRegionAvailabilityOptions) { + const availabilities = useRegionAvailabilities(options); + + return useCallback( + ({ identifier }: CatalogRegion) => Boolean(availabilities[identifier]?.[0]), + [availabilities], + ); } export function useRegionAvailability(regionIdentifier: string) { return useRegionAvailabilities()[regionIdentifier] ?? [false, 'regionNotFound']; } -function checkRegionAvailability(region: CatalogRegion): RegionAvailability { +export type CheckRegionAvailabilityOptions = { + instance?: CatalogInstance; +}; + +function checkRegionAvailability( + region: CatalogRegion, + options: CheckRegionAvailabilityOptions = {}, +): RegionAvailability { if (region.status !== 'available') { return [false, 'unavailable']; } + if (options.instance && region.instances && !region.instances.includes(options.instance.identifier)) { + return [false, 'unavailableForInstance']; + } + return [true]; } @@ -61,19 +82,35 @@ export type InstanceUnavailableReason = export function useInstanceAvailabilities(options: CheckInstanceAvailabilityOptions = {}) { const instances = useInstances(); + const check = useCheckInstanceAvailability(options); + + return useMemo(() => { + return toObject(instances, (instance) => instance.identifier, check); + }, [instances, check]); +} + +export function useIsInstanceAvailable(options: CheckInstanceAvailabilityOptions = {}) { + const availabilities = useInstanceAvailabilities(options); + + return useCallback( + ({ identifier }: CatalogInstance) => Boolean(availabilities[identifier]?.[0]), + [availabilities], + ); +} + +export function useCheckInstanceAvailability(options: CheckInstanceAvailabilityOptions = {}) { const organizationSummary = useOrganizationSummary(); const optionsMemo = useDeepCompareMemo(options); - return useMemo(() => { - return toObject( - instances, - (instance) => instance.identifier, - (instance) => checkInstanceAvailability(instance, organizationSummary, optionsMemo), - ); - }, [instances, organizationSummary, optionsMemo]); + return useCallback( + (instance: CatalogInstance) => { + return checkInstanceAvailability(instance, organizationSummary, optionsMemo); + }, + [organizationSummary, optionsMemo], + ); } -type CheckInstanceAvailabilityOptions = { +export type CheckInstanceAvailabilityOptions = { serviceType?: ServiceType; hasVolumes?: boolean; previousInstance?: CatalogInstance; diff --git a/src/components/activity/activity-resources.tsx b/src/components/activity/activity-resources.tsx index 0b05a3b6..b5e04a98 100644 --- a/src/components/activity/activity-resources.tsx +++ b/src/components/activity/activity-resources.tsx @@ -159,7 +159,7 @@ function ServiceResource({ function RegionResource({ identifier }: { identifier: string }) { return ( - + {identifier} ); diff --git a/src/components/icons.ts b/src/components/icons.ts index 1872e91e..72b9cedb 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -71,14 +71,17 @@ export { default as IconListMinus } from 'lucide-static/icons/list-minus.svg?rea export { default as IconListPlus } from 'lucide-static/icons/list-plus.svg?react'; export { default as IconLock } from 'lucide-static/icons/lock.svg?react'; export { default as IconLogOut } from 'lucide-static/icons/log-out.svg?react'; +export { default as IconMemoryStick } from 'lucide-static/icons/memory-stick.svg?react'; export { default as IconMenu } from 'lucide-static/icons/menu.svg?react'; export { default as IconMessageMoreCircle } from 'lucide-static/icons/message-circle-more.svg?react'; +export { default as IconMicrochip } from 'lucide-static/icons/microchip.svg?react'; export { default as IconMoon } from 'lucide-static/icons/moon.svg?react'; export { default as IconMoveHorizontal } from 'lucide-static/icons/move-horizontal.svg?react'; export { default as IconNewspaper } from 'lucide-static/icons/newspaper.svg?react'; export { default as IconPackage } from 'lucide-static/icons/package.svg?react'; export { default as IconPanelsLeftBottom } from 'lucide-static/icons/panels-left-bottom.svg?react'; export { default as IconPlus } from 'lucide-static/icons/plus.svg?react'; +export { default as IconRadioReceiver } from 'lucide-static/icons/radio-receiver.svg?react'; export { default as IconRefresh } from 'lucide-static/icons/rotate-ccw.svg?react'; export { default as IconRefreshCcw } from 'lucide-static/icons/refresh-ccw.svg?react'; export { default as IconRocket } from 'lucide-static/icons/rocket.svg?react'; diff --git a/src/components/instance-selector/instance-badges.tsx b/src/components/instance-selector/instance-badges.tsx new file mode 100644 index 00000000..c1fd2b13 --- /dev/null +++ b/src/components/instance-selector/instance-badges.tsx @@ -0,0 +1,38 @@ +import { useOrganization } from 'src/api/hooks/session'; +import { CatalogInstance } from 'src/api/model'; +import { useGetHasInstanceQuota } from 'src/application/instance-quota'; + +import { InstanceSelectorBadge } from './instance-selector'; + +type UseGetInstanceBadgesOptions = { + previousInstance?: CatalogInstance; + bestFit?: boolean; + insufficientVRam?: boolean; +}; + +export function useGetInstanceBadges(options: UseGetInstanceBadgesOptions) { + const organization = useOrganization(); + const hasQuotas = useGetHasInstanceQuota(options.previousInstance); + + return (instance: CatalogInstance): InstanceSelectorBadge[] => { + const result = new Array(); + + if (instance.category === 'gpu') { + result.push('new'); + } + + if (organization.plan !== 'hobby' && !hasQuotas(instance)) { + result.push('requiresHigherQuota'); + } + + if (options.insufficientVRam) { + result.push('insufficientVRam'); + } + + if (options.bestFit) { + result.push('bestFit'); + } + + return result; + }; +} diff --git a/src/components/instance-selector/instance-category-tabs.tsx b/src/components/instance-selector/instance-category-tabs.tsx new file mode 100644 index 00000000..37754355 --- /dev/null +++ b/src/components/instance-selector/instance-category-tabs.tsx @@ -0,0 +1,38 @@ +import { TabButtons, TabButton } from '@koyeb/design-system'; +import { CatalogInstance, InstanceCategory } from 'src/api/model'; +import { createTranslate } from 'src/intl/translate'; + +const T = createTranslate('components.instanceSelector.new'); + +const tabs: InstanceCategory[] = ['eco', 'standard', 'gpu']; + +type InstanceCategoryTabsProps = { + category: InstanceCategory; + setCategory: (category: InstanceCategory) => void; + instances: CatalogInstance[]; + setInstance: (instance: CatalogInstance | null) => void; +}; + +export function InstanceCategoryTabs({ + category, + setCategory, + instances, + setInstance, +}: InstanceCategoryTabsProps) { + const handleClick = (category: InstanceCategory) => { + const instance = instances.find((instance) => instance.category === category); + + setCategory(category); + setInstance(instance ?? null); + }; + + return ( + + {tabs.map((value) => ( + handleClick(value)}> + + + ))} + + ); +} diff --git a/src/components/instance-selector/instance-selector.stories.tsx b/src/components/instance-selector/instance-selector.stories.tsx new file mode 100644 index 00000000..52c4b0a0 --- /dev/null +++ b/src/components/instance-selector/instance-selector.stories.tsx @@ -0,0 +1,53 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; + +import { mapCatalogInstance, mapCatalogRegion } from 'src/api/mappers/catalog'; +import { catalogInstanceFixtures, catalogRegionFixtures } from 'src/api/mock/fixtures'; +import { CatalogInstance, InstanceCategory } from 'src/api/model'; +import { hasProperty } from 'src/utils/object'; + +import { InstanceCategoryTabs } from './instance-category-tabs'; +import { InstanceSelector, InstanceSelectorBadge } from './instance-selector'; + +export default { + title: 'Components/InstanceSelector', + parameters: { className: 'max-w-3xl' }, +} satisfies Meta; + +const instances = catalogInstanceFixtures.map(mapCatalogInstance); +const regions = catalogRegionFixtures.map(mapCatalogRegion).filter((region) => region.status === 'available'); + +export const instanceSelector = () => { + const [selectedCategory, setSelectedCategory] = useState('eco'); + const [selectedInstance, setSelectedInstance] = useState(instances[1]!); + const [selectedRegions, setSelectedRegions] = useState([regions[1]!]); + + return ( +
+ + + badges[identifier] ?? []} + /> +
+ ); +}; + +const badges: Record = { + '4xlarge': ['requiresHigherQuota'], + '5xlarge': ['requiresHigherQuota'], + 'gpu-nvidia-rtx-4000-sff-ada': ['new', 'insufficientVRam'], + 'gpu-nvidia-l4': ['new', 'bestFit'], + '2-gpu-nvidia-l4': ['new'], +}; diff --git a/src/components/instance-selector/instance-selector.tsx b/src/components/instance-selector/instance-selector.tsx new file mode 100644 index 00000000..6eb06176 --- /dev/null +++ b/src/components/instance-selector/instance-selector.tsx @@ -0,0 +1,240 @@ +import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; + +import { Badge, RadioInput } from '@koyeb/design-system'; +import { CatalogInstance, CatalogRegion, RegionScope } from 'src/api/model'; +import { formatBytes } from 'src/application/memory'; +import { useMount } from 'src/hooks/lifecycle'; +import { createTranslate } from 'src/intl/translate'; +import { isDefined } from 'src/utils/generic'; + +import { IconCpu, IconMemoryStick, IconMicrochip, IconRadioReceiver } from '../icons'; + +import { RegionSelector } from './region-selector'; + +const T = createTranslate('components.instanceSelector.new'); + +export type InstanceSelectorBadge = + | 'inUse' + | 'new' + | 'comingSoon' + | 'bestFit' + | 'insufficientVRam' + | 'requiresHigherQuota'; + +type InstanceSelectorProps = { + instances: CatalogInstance[]; + regions: CatalogRegion[]; + selectedInstance: CatalogInstance | null; + onInstanceSelected: (instance: CatalogInstance) => void; + selectedRegions: CatalogRegion[]; + onRegionsSelected: (regions: CatalogRegion[]) => void; + getBadges: (instance: CatalogInstance) => InstanceSelectorBadge[]; +}; + +export function InstanceSelector({ + instances, + regions, + selectedInstance, + onInstanceSelected, + selectedRegions, + onRegionsSelected, + getBadges, +}: InstanceSelectorProps) { + const [scope, setScope] = useState(regions[0]?.scope ?? 'metropolitan'); + + const onScopeChanged = (scope: RegionScope) => { + setScope(scope); + onRegionsSelected([regions.find((region) => region.scope === scope)].filter(isDefined)); + }; + + useEffect(() => { + const selected = selectedRegions.filter((region) => regions.includes(region)); + + if (selected.length !== selectedRegions.length) { + onRegionsSelected(selected); + } + }, [regions, selectedRegions, onRegionsSelected]); + + return ( +
+ {instances.map((instance) => ( + onInstanceSelected(instance)} + regionSelector={ + region.scope === scope)} + selected={selectedRegions} + onSelected={onRegionsSelected} + scope={scope} + onScopeChanged={onScopeChanged} + /> + } + /> + ))} +
+ ); +} + +type InstanceItemProps = { + instance: CatalogInstance; + badges: InstanceSelectorBadge[]; + disabled?: boolean; + selected: boolean; + onSelected: () => void; + regionSelector: React.ReactNode; +}; + +export function InstanceItem({ + instance, + disabled, + badges, + selected, + onSelected, + regionSelector, +}: InstanceItemProps) { + const ref = useRef(null); + + useMount(() => { + if (selected) { + ref.current?.scrollIntoView({ block: 'center' }); + } + }); + + return ( + + ); +} + +type InstanceDescriptionProps = { + instance: CatalogInstance; + disabled?: boolean; + selected: boolean; + onSelected: () => void; + badges: React.ReactNode; +}; + +function InstanceDescription({ instance, disabled, selected, onSelected, badges }: InstanceDescriptionProps) { + return ( +
+
+ + {instance.displayName} + {badges} +
+ + +
+ ); +} + +function InstanceSpec({ instance }: { instance: CatalogInstance }) { + return ( +
+
+ + +
+ + {instance.vram && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+
+ ); +} + +function InstanceFooter({ instance }: { instance: CatalogInstance }) { + return ( +
+ +
+ ); +} + +function InstanceBadges({ badges }: { badges: InstanceSelectorBadge[] }) { + return ( + <> + {badges.includes('inUse') && ( + + + + )} + + {badges.includes('new') && ( + + + + )} + + {badges.includes('comingSoon') && ( + + + + )} + + {badges.includes('bestFit') && ( + + + + )} + + {badges.includes('insufficientVRam') && ( + + + + )} + + {badges.includes('requiresHigherQuota') && ( + + + + )} + + ); +} diff --git a/src/components/instance-selector/region-selector.tsx b/src/components/instance-selector/region-selector.tsx new file mode 100644 index 00000000..e350aa31 --- /dev/null +++ b/src/components/instance-selector/region-selector.tsx @@ -0,0 +1,100 @@ +import { CheckboxInput, Collapse, TabButton, TabButtons } from '@koyeb/design-system'; +import { CatalogRegion, RegionScope } from 'src/api/model'; +import { useRegionLatency } from 'src/hooks/region-latency'; +import { createTranslate } from 'src/intl/translate'; + +import { RegionFlag } from '../region-flag'; + +const T = createTranslate('components.instanceSelector.new'); + +const scopes: RegionScope[] = ['continental', 'metropolitan']; + +type RegionSelectorProps = { + expanded: boolean; + regions: CatalogRegion[]; + selected: CatalogRegion[]; + onSelected: (selected: CatalogRegion[]) => void; + scope: RegionScope; + onScopeChanged: (scope: RegionScope) => void; +}; + +export function RegionSelector({ + expanded, + regions, + selected, + onSelected, + scope: currentScope, + onScopeChanged, +}: RegionSelectorProps) { + const toggleRegion = (region: CatalogRegion) => { + const index = selected.indexOf(region); + + if (index === -1) { + onSelected([...selected, region]); + } else { + onSelected(selected.filter((r) => r !== region)); + } + }; + + return ( + +
+
+ +
+ + + {scopes.map((scope) => ( + onScopeChanged(scope)}> + + + ))} + +
+ +
    + {regions.map((region) => ( +
  • + toggleRegion(region)} + /> +
  • + ))} + + {/* todo: empty state */} + {regions.length === 0 && <>No region available} +
+
+ ); +} + +type RegionItemProps = { + region: CatalogRegion; + selected: boolean; + onSelected: () => void; +}; + +function RegionItem({ region, selected, onSelected }: RegionItemProps) { + const latency = useRegionLatency(region); + + return ( + + ); +} diff --git a/src/components/region-flag.tsx b/src/components/region-flag.tsx index 5ec8e230..55365fb3 100644 --- a/src/components/region-flag.tsx +++ b/src/components/region-flag.tsx @@ -1,3 +1,6 @@ +import clsx from 'clsx'; + +import { useRegion } from 'src/api/hooks/catalog'; import af from 'src/icons/flags/AF.png'; import de from 'src/icons/flags/DE.png'; import eu from 'src/icons/flags/EU.png'; @@ -14,14 +17,24 @@ type RegionFlagProps = Omit, 'src'> & identifier?: string; }; -export function RegionFlag({ identifier, ...props }: RegionFlagProps) { - const flag = identifier ? map[identifier] : undefined; +export function RegionFlag({ identifier, className, ...props }: RegionFlagProps) { + const region = useRegion(identifier); + const flag = region?.identifier ? map[region.identifier] : undefined; if (!flag) { return null; } - return ; + return ( + + ); } const map: Record = { diff --git a/src/components/regions-list.tsx b/src/components/regions-list.tsx index eaac5fdb..f64e388b 100644 --- a/src/components/regions-list.tsx +++ b/src/components/regions-list.tsx @@ -14,7 +14,7 @@ export function RegionsList({ identifiers }: { identifiers: string[] }) { .filter(isDefined) .map((region) => (
  • - + {region.displayName}
  • ))} diff --git a/src/icons/flags/AF.png b/src/icons/flags/AF.png index fc7b708d..95d24957 100644 Binary files a/src/icons/flags/AF.png and b/src/icons/flags/AF.png differ diff --git a/src/icons/flags/EU.png b/src/icons/flags/EU.png index d11505d3..6046ce7a 100644 Binary files a/src/icons/flags/EU.png and b/src/icons/flags/EU.png differ diff --git a/src/icons/flags/NEA.png b/src/icons/flags/NEA.png index 9d3625bc..99afc9c8 100644 Binary files a/src/icons/flags/NEA.png and b/src/icons/flags/NEA.png differ diff --git a/src/icons/flags/SA.png b/src/icons/flags/SA.png index e7c4a50d..93d496da 100644 Binary files a/src/icons/flags/SA.png and b/src/icons/flags/SA.png differ diff --git a/src/icons/flags/SEA.png b/src/icons/flags/SEA.png index e5b69ae6..8a7d19f0 100644 Binary files a/src/icons/flags/SEA.png and b/src/icons/flags/SEA.png differ diff --git a/src/intl/en.json b/src/intl/en.json index 19699e6c..518edf34 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -209,6 +209,55 @@ "aiReplyHeader": "Koyeb AI recommends an instance of type {recommendedInstanceType}" } }, + "instanceSelector.new": { + "instanceType": { + "standard": "CPU Standard ✨", + "eco": "CPU Eco 🌱", + "gpu": "GPU 🚀" + }, + "regionScope": { + "continental": "Continental", + "metropolitan": "Metropolitan" + }, + "instanceSpec": { + "cpu": "{value} vCPU", + "ram": "{value} RAM", + "vram": "{value} VRAM", + "disk": "{value} Disk" + }, + "regions": { + "label": "Available in regions", + "latency": "{value, number, :: precision-integer}ms latency", + "checkingLatency": "Checking latency..." + }, + "badge": { + "inUse": "In use", + "new": "New", + "comingSoon": "Coming soon", + "bestFit": "Best fit", + "insufficientVRam": "Insufficient VRAM", + "requiresHigherQuota": "Requires higher quota" + }, + "costs": { + "free": "Free", + "price": "${perMonth}/month • ${perHour}/hour" + }, + "actions": { + "addCreditCard": "Add credit card", + "upgradeDialog": { + "title": "Add credit card", + "description": "To extend your usage quota, upgrade your account to a paid plan and give us an indication of your usage", + "submitButton": "Add credit card" + }, + "requestQuotaIncrease": "Request increase", + "requestQuotaIncreaseDialog": { + "title": "Quota increase request", + "line1": "It looks like you're ready to deploy on a {instance} instance, but we'll need to increase your organization quota first.", + "line2": "Let's chat to learn more about your needs and get started.", + "cta": "Book a call" + } + } + }, "invitation": { "title": "You have been invited to join the {organizationName} organization", "description": "You were invited by {name} ({email})", diff --git a/src/modules/deployment/deployment-info/deployment-definition-dialog.tsx b/src/modules/deployment/deployment-info/deployment-definition-dialog.tsx index 44c195e6..9e2c4204 100644 --- a/src/modules/deployment/deployment-info/deployment-definition-dialog.tsx +++ b/src/modules/deployment/deployment-info/deployment-definition-dialog.tsx @@ -267,7 +267,7 @@ function Instances({ definition }: { definition: DeploymentDefinition }) {
    {definition.regions.map((identifier) => (
    - +
    ))} diff --git a/src/modules/deployment/deployment-logs/replicas.tsx b/src/modules/deployment/deployment-logs/replicas.tsx index 75c41c0d..3b387e92 100644 --- a/src/modules/deployment/deployment-logs/replicas.tsx +++ b/src/modules/deployment/deployment-logs/replicas.tsx @@ -97,7 +97,7 @@ function RegionFilter({ filters, regions }: RegionFilterProps) { return (
    - +
    ); @@ -180,7 +180,7 @@ function Replica({ instances }: { instances: [Instance, ...Instance[]] }) {
    - + @@ -263,7 +263,7 @@ function InstanceDetails({ instance }: { instance: Instance }) { label={} value={
    - +
    } diff --git a/src/modules/deployment/deployment-logs/runtime-logs.tsx b/src/modules/deployment/deployment-logs/runtime-logs.tsx index 43b8d252..7705d4ce 100644 --- a/src/modules/deployment/deployment-logs/runtime-logs.tsx +++ b/src/modules/deployment/deployment-logs/runtime-logs.tsx @@ -173,7 +173,7 @@ function LogsFilters({ form, regions, instances }: LogsFiltersProps) { onItemClick={(region) => region.identifier === form.watch('region') && form.setValue('region', null)} renderItem={(region) => (
    - + {region.displayName}
    )} diff --git a/src/modules/deployment/metadata/runtime-metadata.tsx b/src/modules/deployment/metadata/runtime-metadata.tsx index 7fcf1c87..4ee10c4e 100644 --- a/src/modules/deployment/metadata/runtime-metadata.tsx +++ b/src/modules/deployment/metadata/runtime-metadata.tsx @@ -43,7 +43,7 @@ export function RegionsMetadata({ regions }: { regions: string[] }) { label={} value={
    - + {catalogRegion?.displayName} diff --git a/src/modules/service-form/model-form.tsx b/src/modules/service-form/model-form.tsx index 8c4cb482..ed61fbab 100644 --- a/src/modules/service-form/model-form.tsx +++ b/src/modules/service-form/model-form.tsx @@ -355,11 +355,7 @@ function RegionSection({ form }: { form: ModelForm }) { function SelectRegionItem({ region, disabled }: { region: CatalogRegion; disabled: boolean }) { return (
    - - + {disabled && ( diff --git a/src/modules/service-form/one-click-app-form.tsx b/src/modules/service-form/one-click-app-form.tsx index 935371da..9f4d4dab 100644 --- a/src/modules/service-form/one-click-app-form.tsx +++ b/src/modules/service-form/one-click-app-form.tsx @@ -262,7 +262,7 @@ function RegionSection({ form }: { form: OneClickAppForm }) { itemToValue={(region) => region.identifier} renderItem={(region) => (
    - +
    )} diff --git a/src/modules/service-form/sections/04-environment-variables/regions-scope.tsx b/src/modules/service-form/sections/04-environment-variables/regions-scope.tsx index 4669b663..c3031c91 100644 --- a/src/modules/service-form/sections/04-environment-variables/regions-scope.tsx +++ b/src/modules/service-form/sections/04-environment-variables/regions-scope.tsx @@ -68,7 +68,7 @@ function SelectedRegions({ regions }: { regions: CatalogRegion[] }) { key={region.identifier} identifier={region.identifier} style={{ marginLeft: `${-index / 2}rem` }} - className="size-5 rounded-full shadow-badge" + className="size-5" /> ))} {regions.length} regions @@ -79,7 +79,7 @@ function SelectedRegions({ regions }: { regions: CatalogRegion[] }) { function RegionItem({ region }: { region?: CatalogRegion }) { return ( <> - + ); diff --git a/src/modules/service-form/sections/06-instance/instance-selector.new.tsx b/src/modules/service-form/sections/06-instance/instance-selector.new.tsx new file mode 100644 index 00000000..0ddf2824 --- /dev/null +++ b/src/modules/service-form/sections/06-instance/instance-selector.new.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { useController, useFormContext } from 'react-hook-form'; + +import { useInstance, useInstances, useRegions } from 'src/api/hooks/catalog'; +import { CatalogInstance, CatalogRegion, InstanceCategory } from 'src/api/model'; +import { useIsInstanceAvailable, useIsRegionAvailable } from 'src/application/instance-region-availability'; +import { useGetInstanceBadges } from 'src/components/instance-selector/instance-badges'; +import { InstanceCategoryTabs } from 'src/components/instance-selector/instance-category-tabs'; +import { InstanceSelector as InstanceSelectorComponent } from 'src/components/instance-selector/instance-selector'; +import { hasProperty } from 'src/utils/object'; + +import { ServiceForm } from '../../service-form.types'; +import { useWatchServiceForm } from '../../use-service-form'; + +import { InstanceAlerts } from './instance-alerts'; + +export function InstanceSelectorNew() { + const instances = useInstances(); + const regions = useRegions(); + + const serviceType = useWatchServiceForm('serviceType'); + const hasVolumes = useWatchServiceForm('volumes').filter((volume) => volume.name !== '').length > 0; + const previousInstance = useInstance(useWatchServiceForm('meta.previousInstance')); + + const selectedRegions = useWatchServiceForm('regions').map( + (identifier) => regions.find(hasProperty('identifier', identifier))!, + ); + + const { getValues, setValue, trigger } = useFormContext(); + + const { field } = useController({ name: 'instance' }); + const instance = useInstance(field.value); + + const [category, setCategory] = useState(instance?.category ?? 'standard'); + + const isInstanceAvailable = useIsInstanceAvailable({ serviceType, hasVolumes, previousInstance }); + const isRegionAvailable = useIsRegionAvailable({ instance }); + + const handleInstanceSelected = (instance: CatalogInstance | null) => { + if (!instance || !isInstanceAvailable(instance)) { + field.onChange(null); + return; + } + + field.onChange(instance.identifier); + + if (instance.category === 'eco') { + setValue('scaling.max', getValues('scaling.min')); + void trigger('scaling'); + } + + if (instance.identifier === 'free') { + setValue('scaling.min', 1); + setValue('scaling.max', 1); + void trigger('scaling'); + } + }; + + const handleRegionsSelected = (regions: CatalogRegion[]) => { + setValue( + 'regions', + regions.map((region) => region.identifier), + { shouldValidate: true }, + ); + }; + + const getBadges = useGetInstanceBadges({ + previousInstance: useInstance(useFormContext().watch('meta.previousInstance')), + }); + + return ( + <> + + + + + + + ); +} diff --git a/src/modules/service-form/sections/06-instance/instance-selector.tsx b/src/modules/service-form/sections/06-instance/instance-selector.tsx new file mode 100644 index 00000000..bef9e0d9 --- /dev/null +++ b/src/modules/service-form/sections/06-instance/instance-selector.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { useController, useFormContext } from 'react-hook-form'; + +import { useInstance, useInstances, useRegion } from 'src/api/hooks/catalog'; +import { CatalogInstance, InstanceCategory } from 'src/api/model'; +import { + useInstanceAvailabilities, + useRegionAvailabilities, +} from 'src/application/instance-region-availability'; +import { InstanceSelector as InstanceSelectorComponent } from 'src/components/instance-selector'; +import { hasProperty } from 'src/utils/object'; + +import { ServiceForm } from '../../service-form.types'; +import { useWatchServiceForm } from '../../use-service-form'; + +import { InstanceAlerts } from './instance-alerts'; + +export function InstanceSelector() { + const instances = useInstances(); + + const serviceType = useWatchServiceForm('serviceType'); + const hasVolumes = useWatchServiceForm('volumes').filter((volume) => volume.name !== '').length > 0; + const previousInstance = useInstance(useWatchServiceForm('meta.previousInstance')); + + const regions = useWatchServiceForm('regions'); + const firstRegion = useRegion(regions[0]); + + const instanceAvailabilities = useInstanceAvailabilities({ serviceType, hasVolumes, previousInstance }); + const regionAvailabilities = useRegionAvailabilities(); + + const { getValues, setValue, trigger } = useFormContext(); + + const { field } = useController({ name: 'instance' }); + const instance = useInstance(field.value); + + const [category, setCategory] = useState(instance?.category ?? 'standard'); + + const handleInstanceSelected = (instance: CatalogInstance | null) => { + const [isAvailable] = instance ? (instanceAvailabilities[instance.identifier] ?? [false]) : [false]; + + if (!instance || !isAvailable) { + field.onChange(null); + return; + } + + field.onChange(instance.identifier); + + if (instance.category === 'eco') { + setValue('scaling.max', getValues('scaling.min')); + void trigger('scaling'); + } + + if (instance.identifier === 'free') { + setValue('scaling.min', 1); + setValue('scaling.max', 1); + void trigger('scaling'); + } + + let availableRegions = getValues('regions') + .filter((region) => regionAvailabilities[region]?.[0]) + .filter((region) => instance.regions?.includes(region)); + + if (availableRegions.length === 0) { + availableRegions = [instance?.regions?.[0] ?? 'fra']; + } + + setValue('regions', availableRegions, { shouldValidate: true }); + }; + + return ( + <> + + + instanceAvailabilities[instance] ?? [false, 'instanceNotFound']} + selectedInstance={instances.find(hasProperty('identifier', field.value)) ?? null} + previousInstance={previousInstance} + onInstanceSelected={handleInstanceSelected} + onCategoryChanged={setCategory} + className="w-full" + /> + + ); +} diff --git a/src/modules/service-form/sections/06-instance/instance.section.tsx b/src/modules/service-form/sections/06-instance/instance.section.tsx index 5188f301..02ec6a88 100644 --- a/src/modules/service-form/sections/06-instance/instance.section.tsx +++ b/src/modules/service-form/sections/06-instance/instance.section.tsx @@ -1,77 +1,21 @@ -import { useState } from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; import { Badge } from '@koyeb/design-system'; -import { useInstance, useInstances, useRegion } from 'src/api/hooks/catalog'; -import { CatalogInstance, InstanceCategory } from 'src/api/model'; -import { - useInstanceAvailabilities, - useRegionAvailabilities, -} from 'src/application/instance-region-availability'; -import { InstanceSelector } from 'src/components/instance-selector'; +import { useInstance, useRegions } from 'src/api/hooks/catalog'; +import { RegionFlag } from 'src/components/region-flag'; +import { RegionName } from 'src/components/region-name'; +import { FeatureFlag } from 'src/hooks/feature-flag'; import { createTranslate, Translate } from 'src/intl/translate'; -import { hasProperty } from 'src/utils/object'; import { ServiceFormSection } from '../../components/service-form-section'; import { ServiceForm } from '../../service-form.types'; -import { useWatchServiceForm } from '../../use-service-form'; -import { InstanceAlerts } from './instance-alerts'; +import { InstanceSelector } from './instance-selector'; +import { InstanceSelectorNew } from './instance-selector.new'; const T = createTranslate('modules.serviceForm.instance'); export function InstanceSection() { - const instances = useInstances(); - - const serviceType = useWatchServiceForm('serviceType'); - const hasVolumes = useWatchServiceForm('volumes').filter((volume) => volume.name !== '').length > 0; - const previousInstance = useInstance(useWatchServiceForm('meta.previousInstance')); - - const regions = useWatchServiceForm('regions'); - const firstRegion = useRegion(regions[0]); - - const instanceAvailabilities = useInstanceAvailabilities({ serviceType, hasVolumes, previousInstance }); - const regionAvailabilities = useRegionAvailabilities(); - - const { getValues, setValue, trigger } = useFormContext(); - - const { field } = useController({ name: 'instance' }); - const instance = useInstance(field.value); - - const [category, setCategory] = useState(instance?.category ?? 'standard'); - - const handleInstanceSelected = (instance: CatalogInstance | null) => { - const [isAvailable] = instance ? (instanceAvailabilities[instance.identifier] ?? [false]) : [false]; - - if (!instance || !isAvailable) { - field.onChange(null); - return; - } - - field.onChange(instance.identifier); - - if (instance.category === 'eco') { - setValue('scaling.max', getValues('scaling.min')); - void trigger('scaling'); - } - - if (instance.identifier === 'free') { - setValue('scaling.min', 1); - setValue('scaling.max', 1); - void trigger('scaling'); - } - - let availableRegions = getValues('regions') - .filter((region) => regionAvailabilities[region]?.[0]) - .filter((region) => instance.regions?.includes(region)); - - if (availableRegions.length === 0) { - availableRegions = [instance?.regions?.[0] ?? 'fra']; - } - - setValue('regions', availableRegions, { shouldValidate: true }); - }; - return ( } className="col gap-6" > - - - instanceAvailabilities[instance] ?? [false, 'instanceNotFound']} - selectedInstance={instances.find(hasProperty('identifier', field.value)) ?? null} - previousInstance={previousInstance} - onInstanceSelected={handleInstanceSelected} - onCategoryChanged={setCategory} - className="w-full" - /> + }> + + ); } function SectionTitle() { - const instance = useInstance(useWatchServiceForm('instance')); + const instance = useInstance(useFormContext().watch('instance')); + const regions = useRegions(useFormContext().watch('regions')); if (!instance) { return ; @@ -110,15 +47,40 @@ function SectionTitle() { ); return ( - <> - {instance.displayName} +
    + {instance.displayName} - + - - - - + + + + + + + {regions.length === 1 && ( + + + + + )} + + {regions.length > 1 && ( + + {regions.map((region, index) => ( + + ))} + + {regions.length} regions + + )} + +
    ); } diff --git a/src/pages/home/apps/components/deployment-regions.tsx b/src/pages/home/apps/components/deployment-regions.tsx index 73757edd..e4cdae25 100644 --- a/src/pages/home/apps/components/deployment-regions.tsx +++ b/src/pages/home/apps/components/deployment-regions.tsx @@ -18,7 +18,7 @@ export function DeploymentRegions({ regions }: DeploymentRegionsProps) { return (
    - + {region?.displayName} diff --git a/src/pages/volumes/volume-snapshots/volume-snapshots-list.tsx b/src/pages/volumes/volume-snapshots/volume-snapshots-list.tsx index 51f1672e..a7f9264e 100644 --- a/src/pages/volumes/volume-snapshots/volume-snapshots-list.tsx +++ b/src/pages/volumes/volume-snapshots/volume-snapshots-list.tsx @@ -51,7 +51,7 @@ export function VolumeSnapshotsList({ snapshots }: { snapshots: VolumeSnapshot[] header: , render: (volume) => (
    - +
    ), diff --git a/src/pages/volumes/volumes-list/volumes-list.tsx b/src/pages/volumes/volumes-list/volumes-list.tsx index 21f0f9b0..1e0746f0 100644 --- a/src/pages/volumes/volumes-list/volumes-list.tsx +++ b/src/pages/volumes/volumes-list/volumes-list.tsx @@ -60,7 +60,7 @@ export function VolumesList({ volumes, onCreate }: { volumes: Volume[]; onCreate header: , render: (volume) => (
    - +
    ), diff --git a/src/utils/factories.ts b/src/utils/factories.ts index 375a9ad2..878cdaca 100644 --- a/src/utils/factories.ts +++ b/src/utils/factories.ts @@ -91,6 +91,7 @@ const region = createFactory(() => ({ datacenters: [], hasVolumes: false, category: 'koyeb', + scope: 'metropolitan', })); const instance = createFactory(() => ({