Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,8 @@
"backend": "Backend",
"backend_plural": "Backends",
"availability_not_available": "Not available",
"availability_available": "Available",
"availability_unknown": "Unknown",
"availability_no_quota": "No quota",
"availability_no_balance": "No balance",
"availability_busy": "Busy",
Expand Down
64 changes: 50 additions & 14 deletions frontend/src/pages/Offers/List/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

import {
formatGpuVendor,
getOfferAvailability,
getOfferResources,
getOfferSearchParams,
rangeToObject,
renderRangeJSX,
toCpuCard,
toOfferCard,
} from './helpers';
Expand All @@ -19,6 +23,16 @@ const makeGpu = (name: string, price: { min: number; max: number }, availability
});

describe('Offers helpers', () => {
test.each([
[{ min: 52.7436, max: 217.54 }, '52.74..217.54'],
[{ min: 1.236, max: 1.236 }, '1.24'],
[{ min: 2, max: 2 }, '2'],
] as const)('displays price %j as %s', (price, expected) => {
const html = renderToStaticMarkup(createElement('span', null, renderRangeJSX(price)));

expect(html.replace(/<[^>]*>/g, '')).toBe(expected);
});

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);
Expand Down Expand Up @@ -104,20 +118,42 @@ describe('Offers helpers', () => {
});

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),
);
[['available'], ['available']],
[['unknown'], ['unknown']],
[
['unknown', 'available', 'unknown'],
['available', 'unknown'],
],
[
['no_quota', 'no_balance', 'not_available', 'no_quota'],
['no_balance', 'no_quota', 'not_available'],
],
[
['not_available', 'no_quota'],
['no_quota', 'not_available'],
],
[
['no_quota', 'not_available'],
['no_quota', 'not_available'],
],
[
['no_quota', 'available'],
['available', 'no_quota'],
],
[
['idle', 'busy'],
['busy', 'idle'],
],
[
['no_balance', 'unknown'],
['no_balance', 'unknown'],
],
[[], []],
] as [TAvailability[], TAvailability[]][])('availability %j shows the complete sorted set %j', (statuses, expected) => {
const original = [...statuses];
expect(getOfferAvailability(statuses)).toEqual(expected);
expect(statuses).toEqual(original);
});

test('rangeToObject parses open and closed ranges', () => {
expect(rangeToObject('0')).toEqual({ min: 0, max: 0 });
Expand Down
15 changes: 4 additions & 11 deletions frontend/src/pages/Offers/List/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ export const getPropertyFilterOptions = (gpus: (IGpu | ICpuGroup)[]) => {
};
};

const AVAILABLE_STATUSES = new Set<TAvailability>(['unknown', 'available']);

export const formatGpuVendor = (vendor: string) => (vendor === 'google' ? 'TPU' : vendor.toUpperCase());

export type OfferCard = IGpu & {
Expand Down Expand Up @@ -133,12 +131,7 @@ export const getOfferSearchParams = (searchParams: URLSearchParams, offer?: Offe
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 getOfferAvailability = (availability: TAvailability[]) => Array.from(new Set(availability)).sort();

export const getFleetFilterValue = (fleet: IFleet, selectedProjectName?: string) => {
if (selectedProjectName && fleet.project_name === selectedProjectName) {
Expand All @@ -162,15 +155,15 @@ export const renderRangeJSX = (range: { min?: number; max?: number }) => {
if (typeof range.min === 'number' && typeof range.max === 'number' && range.max != range.min) {
return (
<>
{Number(range.min.toFixed(4))}
{Number(range.min.toFixed(2))}
<span className={styles.greyText}>{rangeSeparator}</span>
{Number(range.max.toFixed(4))}
{Number(range.max.toFixed(2))}
</>
);
}

const value = range.min ?? range.max;
return value === undefined ? undefined : Number(value.toFixed(4));
return value === undefined ? undefined : Number(value.toFixed(2));
};

export const rangeToObject = (
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/pages/Offers/List/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getRequestParams } from './index';

jest.mock('@cloudscape-design/components/badge', () => ({}));
jest.mock('components', () => ({}));
jest.mock('hooks', () => ({}));
jest.mock('libs', () => ({}));
jest.mock('services/gpu', () => ({}));
jest.mock('./hooks/useFilters', () => ({}));

describe('Offer request parameters', () => {
test('includes spot and on-demand offers when no spot policy is selected', () => {
const params = getRequestParams({ project_name: 'main' });

expect(params.run_spec.configuration.spot_policy).toBe('auto');
});

test.each(['spot', 'on-demand', 'auto'] as const)('preserves an explicit %s policy', (spot_policy) => {
const params = getRequestParams({ project_name: 'main', spot_policy });

expect(params.run_spec.configuration.spot_policy).toBe(spot_policy);
});
});
23 changes: 5 additions & 18 deletions frontend/src/pages/Offers/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import Badge from '@cloudscape-design/components/badge';
import { skipToken } from '@reduxjs/toolkit/query';

import { Alert, Button, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter, SpaceBetween, Toggle } from 'components';
import { Alert, Button, Cards, CardsProps, MultiselectCSD, PropertyFilter, SpaceBetween, Toggle } from 'components';

import { useCollection } from 'hooks';
import { getServerError } from 'libs';
Expand Down Expand Up @@ -88,7 +88,7 @@ export const getRequestParams = ({
...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax as unknown as TRange } : {}),
},
},
spot_policy,
spot_policy: spot_policy ?? 'auto',
volumes: [],
files: [],
setup: [],
Expand Down Expand Up @@ -293,9 +293,6 @@ export const OfferList: React.FC<OfferListProps> = ({
id: 'resources',
content: (gpu) => {
const availability = getOfferAvailability(gpu.availability);
const label = availability && (
<span className={styles.greyText}>{t(`offer.availability_${availability}`)}</span>
);
return (
<div className={styles.cardContent}>
<div>
Expand All @@ -313,19 +310,9 @@ export const OfferList: React.FC<OfferListProps> = ({
</div>
<div>{gpu.spot.join(', ') || '-'}</div>
{ungrouped && gpu.region && <div>{gpu.region}</div>}
{label &&
(gpu.availability.length === 1 ? (
label
) : (
<Popover
dismissButton={false}
position="top"
size="small"
content={gpu.availability.map((a) => t(`offer.availability_${a}`)).join(', ')}
>
{label}
</Popover>
))}
<span className={styles.greyText}>
{availability.map((a) => t(`offer.availability_${a}`)).join(', ')}
</span>
</div>
);
},
Expand Down
16 changes: 5 additions & 11 deletions src/dstack/_internal/cli/utils/gpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from rich.table import Table

from dstack._internal.cli.models.offers import OfferCommandGroupByGpuOutput, OfferRequirements
from dstack._internal.cli.utils.common import console, format_instance_availability
from dstack._internal.cli.utils.common import console
from dstack._internal.core.models.gpus import GpuGroup
from dstack._internal.core.models.instances import InstanceAvailability
from dstack._internal.core.models.profiles import SpotPolicy
from dstack._internal.core.models.runs import Requirements, RunSpec, get_policy_map

Expand Down Expand Up @@ -61,7 +60,7 @@ def print_gpu_table(gpus: List[GpuGroup], run_spec: RunSpec, group_by: List[str]
table.add_column(backend_column, style="grey58", ratio=2)
if region_column:
table.add_column(region_column, style="grey58", ratio=2)
table.add_column()
table.add_column("AVAILABILITY")

for i, gpu_group in enumerate(gpus, start=1):
backend_text = ""
Expand Down Expand Up @@ -116,15 +115,10 @@ def print_gpu_table(gpus: List[GpuGroup], run_spec: RunSpec, group_by: List[str]
max_formatted = f"{gpu_group.price.max:.4f}".rstrip("0").rstrip(".")
price_display = f"{min_formatted}..{max_formatted}"

availability = ""
has_available = any(
av in (InstanceAvailability.UNKNOWN, InstanceAvailability.AVAILABLE)
for av in gpu_group.availability
availability = ", ".join(
av.value.replace("_", " ")
for av in sorted(set(gpu_group.availability), key=lambda av: av.value)
)
if not has_available:
availability = ", ".join(
map(format_instance_availability, set(gpu_group.availability))
)

secondary_style = "grey58"
row_data = [
Expand Down
2 changes: 1 addition & 1 deletion src/dstack/_internal/core/models/gpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class BackendGpu(CoreModel):
name: str
memory_mib: int
vendor: gpuhunt.AcceleratorVendor
availability: InstanceAvailability
availability: List[InstanceAvailability]
spot: bool
count: int
price: float
Expand Down
Loading
Loading