-
-
`Use: ${value}`,
- }}
- filteringOptions={filteringOptions}
- filteringProperties={filteringProperties}
- filteringStatusType={filteringStatusType}
- onLoadItems={handleLoadItems}
- />
-
-
-
-
- {t('fleets.instances.active_only')}
-
+ !fleetId && (
+
+
+
`Use: ${value}`,
+ }}
+ filteringOptions={filteringOptions}
+ filteringProperties={filteringProperties}
+ filteringStatusType={filteringStatusType}
+ onLoadItems={handleLoadItems}
+ />
+
+
{activeInstancesToggle}
-
+ )
}
- footer={
}
+ footer={isLoadingMore &&
}
/>
);
};
diff --git a/frontend/src/pages/Offers/List/helpers.test.ts b/frontend/src/pages/Offers/List/helpers.test.ts
index 01f8a4677a..4f8b5a2d9b 100644
--- a/frontend/src/pages/Offers/List/helpers.test.ts
+++ b/frontend/src/pages/Offers/List/helpers.test.ts
@@ -1,4 +1,12 @@
-import { rangeToObject, sortGpusByPriceDesc } from './helpers';
+import {
+ formatGpuVendor,
+ getOfferAvailability,
+ getOfferResources,
+ getOfferSearchParams,
+ rangeToObject,
+ toCpuCard,
+ toOfferCard,
+} from './helpers';
const makeGpu = (name: string, price: { min: number; max: number }, availability: TAvailability[] = ['available']): IGpu => ({
name,
@@ -11,25 +19,116 @@ const makeGpu = (name: string, price: { min: number; max: number }, availability
});
describe('Offers helpers', () => {
- test('sortGpusByPriceDesc puts available GPUs first, most expensive first', () => {
- const gpus = [
- makeGpu('A10', { min: 0.5, max: 4 }),
- makeGpu('B200', { min: 6, max: 48 }, ['not_available']),
- makeGpu('H100', { min: 2, max: 16 }),
- makeGpu('H200', { min: 3, max: 24 }, ['no_quota', 'idle']),
- makeGpu('L4', { min: 0.5, max: 2 }),
- ];
+ test('GPU selection preserves precise memory and explicit fleet scope', () => {
+ const gpu = { ...makeGpu('H100', { min: 1, max: 8 }), memory_mib: 275032 };
+ const params = getOfferSearchParams(new URLSearchParams('project_name=main&fleet=chosen&group_by=gpu'), gpu);
+ expect(params.get('gpu_memory')).toBe('268.5859375GB');
+ expect(params.get('gpu_count')).toBe('1..8');
+ expect(params.get('fleet')).toBe('chosen');
+ expect(params.has('group_by')).toBe(false);
+ expect(getOfferResources(gpu)).toEqual({ gpu: 'nvidia:H100:1..8:268.5859375GB' });
+ expect(getOfferSearchParams(new URLSearchParams(), gpu).has('fleet')).toBe(false);
+ });
+
+ test('CPU selection replaces GPU constraints and keeps the offer location', () => {
+ const cpu = {
+ ...makeGpu('CPU', { min: 0, max: 0 }),
+ count: { min: 2, max: 2 },
+ memory_mib: 1901,
+ backend: 'kubernetes' as const,
+ region: 'cluster',
+ cpu: { cpus: 2, cpu_arch: 'arm', memory_mib: 1901, gpus: [], spot: false },
+ };
+ const params = getOfferSearchParams(new URLSearchParams('gpu_name=H100&gpu_vendor=nvidia&gpu_memory=80GB'), cpu);
+ expect(Object.fromEntries(params)).toEqual({
+ gpu_count: '0',
+ cpu_count: '2',
+ cpu_arch: 'arm',
+ memory: '1.8564453125GB',
+ backend: 'kubernetes',
+ region: 'cluster',
+ spot_policy: 'on-demand',
+ });
+ expect(getOfferResources(cpu)).toEqual({ gpu: 0, cpu: 'arm:2', memory: '1.8564453125GB' });
+ });
+
+ test('grouped CPU selection preserves ranges without restricting a mixed architecture group', () => {
+ const cpu = toCpuCard({
+ cpu_arch: ['arm', 'x86'],
+ count: { min: 2, max: 32 },
+ memory_mib: { min: 8192, max: 65536 },
+ price: { min: 0.1, max: 1 },
+ backends: ['aws', 'kubernetes'],
+ availability: ['available'],
+ spot: ['on-demand', 'spot'],
+ });
+ expect(cpu.cpu?.architectures).toEqual(['arm', 'x86']);
+ expect(getOfferResources(cpu)).toEqual({ gpu: 0, cpu: '2..32', memory: '8GB..64GB' });
+ expect(Object.fromEntries(getOfferSearchParams(new URLSearchParams('cpu_arch=arm'), cpu))).toEqual({
+ gpu_count: '0',
+ cpu_count: '2..32',
+ memory: '8GB..64GB',
+ spot_policy: 'auto',
+ });
+ });
- expect(sortGpusByPriceDesc(gpus).map(({ name }) => name)).toEqual(['H200', 'H100', 'A10', 'L4', 'B200']);
+ test('Google accelerators are displayed as TPU without changing resource values', () => {
+ const gpu = { ...makeGpu('v5p', { min: 1, max: 1 }), vendor: 'google' };
+ expect(formatGpuVendor(gpu.vendor)).toBe('TPU');
+ expect(formatGpuVendor('amd')).toBe('AMD');
+ expect(getOfferResources(gpu)).toEqual({ gpu: 'google:v5p:1..8:80GB' });
+ expect(getOfferSearchParams(new URLSearchParams(), gpu).get('gpu_vendor')).toBe('google');
});
+ test('individual cards use system RAM for CPUs and per-GPU VRAM for GPUs, keeping the whole-offer price', () => {
+ const resources: IResources = { cpus: 8, cpu_arch: 'arm', memory_mib: 32768, gpus: [], spot: false };
+ const offer: IInstanceOffer = {
+ backend: 'aws',
+ region: 'eu-west-1',
+ instance: { name: 'test', resources },
+ price: 2,
+ availability: 'available',
+ };
+ const cpu = toOfferCard(offer);
+ expect(cpu.cpu?.cpu_arch).toBe('arm');
+ expect(cpu.count).toEqual({ min: 8, max: 8 });
+ expect(cpu.memory_mib).toBe(32768);
+
+ resources.gpus = [makeGpu('H100', { min: 1, max: 1 }), makeGpu('H100', { min: 1, max: 1 })];
+ const gpu = toOfferCard(offer);
+ expect(gpu.cpu).toBeUndefined();
+ expect(gpu.name).toBe('H100');
+ expect(gpu.count).toEqual({ min: 2, max: 2 });
+ expect(gpu.memory_mib).toBe(81920);
+ expect(gpu.price).toEqual({ min: 2, max: 2 });
+ });
+
+ test.each([
+ [['no_quota', 'no_balance', 'not_available'], 'not_available'],
+ [['no_quota', 'no_balance'], 'no_balance'],
+ [['no_quota'], 'no_quota'],
+ [['idle'], 'idle'],
+ [['busy'], 'busy'],
+ [['no_quota', 'available'], undefined],
+ [['not_available', 'idle'], 'idle'],
+ [['busy', 'idle'], 'idle'],
+ [['idle', 'available'], undefined],
+ [['no_balance', 'unknown'], undefined],
+ [[], undefined],
+ ] as [TAvailability[], TAvailability | undefined][])('availability %j shows %s', (statuses, expected) =>
+ expect(getOfferAvailability(statuses)).toBe(expected),
+ );
+
test('rangeToObject parses open and closed ranges', () => {
+ expect(rangeToObject('0')).toEqual({ min: 0, max: 0 });
+ expect(rangeToObject('2')).toEqual({ min: 2, max: 2 });
expect(rangeToObject('1..')).toEqual({ min: 1 });
expect(rangeToObject('..4')).toEqual({ max: 4 });
expect(rangeToObject('1..4')).toEqual({ min: 1, max: 4 });
});
test('rangeToObject parses GB ranges for memory', () => {
+ expect(rangeToObject('1.8603515625GB', { requireUnit: true })).toEqual({ min: 1.8603515625, max: 1.8603515625 });
expect(rangeToObject('24GB..', { requireUnit: true })).toEqual({ min: 24 });
expect(rangeToObject('..80GB', { requireUnit: true })).toEqual({ max: 80 });
expect(rangeToObject('40GB..80GB', { requireUnit: true })).toEqual({ min: 40, max: 80 });
diff --git a/frontend/src/pages/Offers/List/helpers.tsx b/frontend/src/pages/Offers/List/helpers.tsx
index efbe23aac7..47f177968d 100644
--- a/frontend/src/pages/Offers/List/helpers.tsx
+++ b/frontend/src/pages/Offers/List/helpers.tsx
@@ -10,13 +10,15 @@ export function convertMiBToGB(mib: number) {
return mib / 1024;
}
-export const getPropertyFilterOptions = (gpus: IGpu[]) => {
+export const getPropertyFilterOptions = (gpus: (IGpu | ICpuGroup)[]) => {
const names = new Set
();
+ const vendors = new Set();
const backends = new Set();
const counts = new Set();
gpus.forEach((gp) => {
- names.add(gp.name);
+ if ('name' in gp) names.add(gp.name);
+ if ('vendor' in gp && gp.vendor) vendors.add(gp.vendor);
if (gp.backend) {
backends.add(gp.backend);
@@ -35,21 +37,108 @@ export const getPropertyFilterOptions = (gpus: IGpu[]) => {
return {
names,
+ vendors,
backends,
counts,
};
};
-const AVAILABLE_STATUSES = new Set(['unknown', 'available', 'idle']);
+const AVAILABLE_STATUSES = new Set(['unknown', 'available']);
-const isGpuAvailable = (gpu: IGpu) => gpu.availability.some((availability) => AVAILABLE_STATUSES.has(availability));
+export const formatGpuVendor = (vendor: string) => (vendor === 'google' ? 'TPU' : vendor.toUpperCase());
-// Available first, as the server sorts, but the most expensive (best) GPUs before cheaper ones.
-export const sortGpusByPriceDesc = (gpus: IGpu[]): IGpu[] =>
- [...gpus].sort(
- (a, b) =>
- Number(isGpuAvailable(b)) - Number(isGpuAvailable(a)) || b.price.min - a.price.min || b.price.max - a.price.max,
- );
+export type OfferCard = IGpu & {
+ cpu?: IResources & {
+ architectures?: ICpuGroup['cpu_arch'];
+ memory_range?: ICpuGroup['memory_mib'];
+ };
+};
+
+export const toCpuCard = ({ cpu_arch, memory_mib, ...group }: ICpuGroup): OfferCard => ({
+ ...group,
+ name: 'CPU',
+ vendor: '',
+ memory_mib: memory_mib.min,
+ cpu: {
+ cpus: group.count.min,
+ cpu_arch: cpu_arch.length === 1 ? cpu_arch[0] : null,
+ memory_mib: memory_mib.min,
+ architectures: cpu_arch,
+ memory_range: memory_mib,
+ gpus: [],
+ spot: group.spot.length === 1 && group.spot[0] === 'spot',
+ },
+});
+
+export const getCpuMemory = (offer: OfferCard) => {
+ const range = offer.cpu?.memory_range ?? { min: offer.memory_mib, max: offer.memory_mib };
+ const min = `${convertMiBToGB(range.min)}GB`;
+ return range.min === range.max ? min : `${min}..${convertMiBToGB(range.max)}GB`;
+};
+
+export const toOfferCard = ({ backend, region, instance: { resources }, price, availability }: IInstanceOffer): OfferCard => {
+ const gpu = resources.gpus[0];
+ const count = gpu ? resources.gpus.length : resources.cpus;
+ return {
+ name: gpu?.name ?? 'CPU',
+ vendor: gpu?.vendor ?? '',
+ memory_mib: gpu?.memory_mib ?? resources.memory_mib,
+ count: { min: count, max: count },
+ price: { min: price, max: price },
+ availability: [availability],
+ spot: [resources.spot ? 'spot' : 'on-demand'],
+ backend,
+ region,
+ cpu: gpu ? undefined : resources,
+ };
+};
+
+export const getOfferResources = (offer: OfferCard): TResourceRequest =>
+ offer.cpu
+ ? {
+ gpu: 0,
+ cpu: [offer.cpu.cpu_arch, renderRange(offer.count)].filter(Boolean).join(':'),
+ memory: getCpuMemory(offer),
+ }
+ : {
+ gpu: [offer.vendor, offer.name, renderRange(offer.count), `${convertMiBToGB(offer.memory_mib)}GB`]
+ .filter(Boolean)
+ .join(':'),
+ };
+
+export const getOfferSearchParams = (searchParams: URLSearchParams, offer?: OfferCard) => {
+ const params = new URLSearchParams(searchParams);
+ params.delete('group_by');
+
+ if (!offer) return params;
+
+ ['gpu_vendor', 'gpu_name', 'gpu_count', 'gpu_memory'].forEach((key) => params.delete(key));
+
+ if (offer.cpu) {
+ params.set('gpu_count', '0');
+ params.set('cpu_count', renderRange(offer.count) ?? '1');
+ params.delete('cpu_arch');
+ if (offer.cpu.cpu_arch) params.set('cpu_arch', offer.cpu.cpu_arch);
+ params.set('memory', getCpuMemory(offer));
+ } else {
+ params.set('gpu_vendor', offer.vendor);
+ params.set('gpu_name', offer.name);
+ params.set('gpu_count', renderRange(offer.count) ?? '1');
+ params.set('gpu_memory', `${convertMiBToGB(offer.memory_mib)}GB`);
+ }
+
+ if (offer.backend) params.set('backend', offer.backend);
+ if (offer.region) params.set('region', offer.region);
+ params.set('spot_policy', offer.spot.length === 1 ? offer.spot[0] : 'auto');
+ return params;
+};
+
+export const getOfferAvailability = (availability: TAvailability[]) =>
+ availability.some((status) => AVAILABLE_STATUSES.has(status))
+ ? undefined
+ : (['idle', 'not_available', 'no_balance', 'no_quota', 'busy'] as const).find((status) =>
+ availability.includes(status),
+ );
export const getFleetFilterValue = (fleet: IFleet, selectedProjectName?: string) => {
if (selectedProjectName && fleet.project_name === selectedProjectName) {
@@ -73,18 +162,19 @@ export const renderRangeJSX = (range: { min?: number; max?: number }) => {
if (typeof range.min === 'number' && typeof range.max === 'number' && range.max != range.min) {
return (
<>
- {round(range.min)}
+ {Number(range.min.toFixed(4))}
{rangeSeparator}
- {round(range.max)}
+ {Number(range.max.toFixed(4))}
>
);
}
- return range.min?.toString() ?? range.max?.toString();
+ const value = range.min ?? range.max;
+ return value === undefined ? undefined : Number(value.toFixed(4));
};
export const rangeToObject = (
- range: RequestParam,
+ range: RequestParam | number,
{
requireUnit = false,
}: {
@@ -113,6 +203,10 @@ export const rangeToObject = (
const min = parseBound(minString);
const max = parseBound(maxString);
+ if (!range.includes(rangeSeparator) && typeof min === 'number') {
+ return { min, max: min };
+ }
+
if (typeof min === 'number' && typeof max === 'number') {
return { min, max };
}
@@ -124,13 +218,13 @@ export const rangeToObject = (
if (typeof max === 'number') {
return { max };
}
+
+ return;
}
if (typeof range === 'number') {
return requireUnit ? undefined : { min: range, max: range };
}
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
return range;
};
diff --git a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx
index 452cc78b41..f3fb300f69 100644
--- a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx
+++ b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx
@@ -10,12 +10,16 @@ export const useEmptyMessages = ({
clearFilter,
projectName,
groupBySelected,
+ ungrouped,
hasCustomFilters,
+ isFleet,
}: {
clearFilter?: () => void;
projectName?: string;
groupBySelected?: boolean;
+ ungrouped?: boolean;
hasCustomFilters?: boolean;
+ isFleet?: boolean;
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -38,7 +42,7 @@ export const useEmptyMessages = ({
);
}
- if (!groupBySelected) {
+ if (!groupBySelected && !ungrouped) {
return (
-