From afbf46ba66692c839c80febebfa6b0920a09ae43 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Tue, 26 Aug 2025 02:22:41 +0300 Subject: [PATCH 1/3] [Feature]: UI for offers #3004 --- frontend/src/api.ts | 2 + frontend/src/layouts/AppLayout/hooks.ts | 1 + frontend/src/locale/en.json | 17 +- .../pages/Instances/List/hooks/useFilters.ts | 2 - frontend/src/pages/Offers/List/helpers.ts | 62 ++++++ .../Offers/List/hooks/useEmptyMessages.tsx | 36 ++++ .../src/pages/Offers/List/hooks/useFilters.ts | 126 +++++++++++ frontend/src/pages/Offers/List/index.tsx | 204 ++++++++++++++++++ .../src/pages/Offers/List/styles.module.scss | 22 ++ frontend/src/pages/Offers/index.ts | 1 + frontend/src/router.tsx | 7 + frontend/src/routes.ts | 4 + frontend/src/services/gpu.ts | 29 +++ frontend/src/store.ts | 4 + frontend/src/types/backend.d.ts | 58 ++--- frontend/src/types/gpu.ts | 92 ++++++++ 16 files changed, 637 insertions(+), 30 deletions(-) create mode 100644 frontend/src/pages/Offers/List/helpers.ts create mode 100644 frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx create mode 100644 frontend/src/pages/Offers/List/hooks/useFilters.ts create mode 100644 frontend/src/pages/Offers/List/index.tsx create mode 100644 frontend/src/pages/Offers/List/styles.module.scss create mode 100644 frontend/src/pages/Offers/index.ts create mode 100644 frontend/src/services/gpu.ts create mode 100644 frontend/src/types/gpu.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7150a2cfcc..661f72ef55 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -105,6 +105,8 @@ export const API = { SECRETS_UPDATE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/create_or_update`, SECRETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/delete`, + // GPUS + GPUS_LIST: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/gpus/list`, }, BACKENDS: { diff --git a/frontend/src/layouts/AppLayout/hooks.ts b/frontend/src/layouts/AppLayout/hooks.ts index 9028a86218..ef53a0082e 100644 --- a/frontend/src/layouts/AppLayout/hooks.ts +++ b/frontend/src/layouts/AppLayout/hooks.ts @@ -24,6 +24,7 @@ export const useSideNavigation = () => { const generalLinks = [ { type: 'link', text: t('navigation.runs'), href: ROUTES.RUNS.LIST }, + { type: 'link', text: t('navigation.offers'), href: ROUTES.OFFERS.LIST }, { type: 'link', text: t('navigation.models'), href: ROUTES.MODELS.LIST }, { type: 'link', text: t('navigation.fleets'), href: ROUTES.FLEETS.LIST }, { type: 'link', text: t('navigation.instances'), href: ROUTES.INSTANCES.LIST }, diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 15b7c9cc5d..6c25f24520 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -78,7 +78,8 @@ "billing": "Billing", "resources": "Resources", "volumes": "Volumes", - "instances": "Instances" + "instances": "Instances", + "offers": "Offers" }, "backend": { @@ -446,6 +447,20 @@ "size": "Size" } }, + "offer": { + "title": "Offers", + "project_name_label": "Project name", + "filter_property_placeholder": "Filter offers by properties", + "backend": "Backend", + "region": "Region", + "count": "Count", + "price": "Price", + "memory_mib": "Memmory", + "empty_message_title": "No offers", + "empty_message_text": "No offers to display.", + "nomatch_message_title": "No matches", + "nomatch_message_text": "We can't find a match." + }, "models": { "model_name": "Name", diff --git a/frontend/src/pages/Instances/List/hooks/useFilters.ts b/frontend/src/pages/Instances/List/hooks/useFilters.ts index a15a7ca3e0..55453c33e4 100644 --- a/frontend/src/pages/Instances/List/hooks/useFilters.ts +++ b/frontend/src/pages/Instances/List/hooks/useFilters.ts @@ -20,8 +20,6 @@ export const useFilters = (localStorePrefix = 'instances-list-page') => { const { projectOptions } = useProjectFilter({ localStorePrefix }); const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => { - console.log(requestParamsToTokens({ searchParams, filterKeys })); - return requestParamsToTokens({ searchParams, filterKeys }); }); diff --git a/frontend/src/pages/Offers/List/helpers.ts b/frontend/src/pages/Offers/List/helpers.ts new file mode 100644 index 0000000000..9af2465429 --- /dev/null +++ b/frontend/src/pages/Offers/List/helpers.ts @@ -0,0 +1,62 @@ +const rangeSeparator = '..'; + +export const getPropertyFilterOptions = (gpus: IGpu[]) => { + const names = new Set(); + const backends = new Set(); + const counts = new Set(); + + gpus.forEach((gp) => { + names.add(gp.name); + + if (gp.backend) { + backends.add(gp.backend); + } + + if (gp.backends?.length) { + gp.backends.forEach((i) => backends.add(i)); + } + + const countRange = renderRange(gp.count); + + if (gp.count && countRange) { + counts.add(countRange); + } + }); + + return { + names, + backends, + counts, + }; +}; + +const round = (number: number) => Math.round(number * 100) / 100; + +export const renderRange = (range: { min?: number; max?: number }) => { + if (typeof range.min === 'number' && typeof range.max === 'number' && range.max != range.min) { + return `${round(range.min)}${rangeSeparator}${round(range.max)}`; + } + + return range.min?.toString() ?? range.max?.toString(); +}; + +export const stringRangeToObject = (rangeString: string): { min?: number; max?: number } | undefined => { + if (!rangeString) return; + + const [minString, maxString] = rangeString.split(rangeSeparator); + + const min = Number(minString); + const max = Number(maxString); + + if (!isNaN(min) && !isNaN(max)) { + return { min, max }; + } + + if (!isNaN(min)) { + return { min, max: min }; + } + + if (!isNaN(max)) { + return { min: max, max }; + } +}; diff --git a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx new file mode 100644 index 0000000000..9a010fb65e --- /dev/null +++ b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ListEmptyMessage } from 'components'; + +export const useEmptyMessages = ({ + clearFilter, + isDisabledClearFilter, +}: { + clearFilter?: () => void; + isDisabledClearFilter?: boolean; +}) => { + const { t } = useTranslation(); + + const renderEmptyMessage = useCallback<() => React.ReactNode>(() => { + return ( + + + + ); + }, [clearFilter, isDisabledClearFilter]); + + const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => { + return ( + + + + ); + }, [clearFilter, isDisabledClearFilter]); + + return { renderEmptyMessage, renderNoMatchMessage } as const; +}; diff --git a/frontend/src/pages/Offers/List/hooks/useFilters.ts b/frontend/src/pages/Offers/List/hooks/useFilters.ts new file mode 100644 index 0000000000..34bc84bcd7 --- /dev/null +++ b/frontend/src/pages/Offers/List/hooks/useFilters.ts @@ -0,0 +1,126 @@ +import { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import type { PropertyFilterProps } from 'components'; + +import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams, tokensToSearchParams } from 'libs/filters'; + +import { getPropertyFilterOptions } from '../helpers'; + +type Args = { + gpus: IGpu[]; +}; + +type RequestParamsKeys = 'gpu_name' | 'gpu_count' | 'gpu_memory' | 'backend'; + +export const filterKeys: Record = { + GPU_NAME: 'gpu_name', + GPU_COUNT: 'gpu_count', + GPU_MEMORY: 'gpu_memory', + BACKEND: 'backend', +}; + +const multipleChoiseKeys: RequestParamsKeys[] = ['gpu_name', 'backend']; + +export const useFilters = ({ gpus }: Args) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); + + const clearFilter = () => { + setSearchParams({}); + setPropertyFilterQuery(EMPTY_QUERY); + }; + + const filteringOptions = useMemo(() => { + const options: PropertyFilterProps.FilteringOption[] = []; + + const { names, backends, counts } = getPropertyFilterOptions(gpus); + + Array.from(names).forEach((name) => { + options.push({ + propertyKey: filterKeys.GPU_NAME, + value: name, + }); + }); + + Array.from(backends).forEach((backend) => { + options.push({ + propertyKey: filterKeys.BACKEND, + value: backend, + }); + }); + + Array.from(counts).forEach((count) => { + options.push({ + propertyKey: filterKeys.GPU_COUNT, + value: count, + }); + }); + + return options; + }, [gpus]); + + const filteringProperties = [ + { + key: filterKeys.GPU_NAME, + operators: ['='], + propertyLabel: 'GPU Name', + }, + { + key: filterKeys.GPU_COUNT, + operators: ['='], + propertyLabel: 'GPU Count', + }, + // { + // key: filterKeys.GPU_MEMORY, + // operators: ['='], + // propertyLabel: 'GPU Memory', + // }, + { + key: filterKeys.BACKEND, + operators: ['='], + propertyLabel: 'Backend', + }, + ]; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + const { tokens, operation } = detail; + + const filteredTokens = tokens.filter((token, tokenIndex) => { + return ( + multipleChoiseKeys.includes(token.propertyKey as RequestParamsKeys) || + !tokens.some((item, index) => token.propertyKey === item.propertyKey && index > tokenIndex) + ); + }); + + setSearchParams(tokensToSearchParams(filteredTokens)); + + setPropertyFilterQuery({ + operation, + tokens: filteredTokens, + }); + }; + + const filteringRequestParams = useMemo(() => { + const params = tokensToRequestParams({ + tokens: propertyFilterQuery.tokens, + arrayFieldKeys: multipleChoiseKeys, + }); + + return { + ...params, + } as Partial; + }, [propertyFilterQuery]); + + return { + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + } as const; +}; diff --git a/frontend/src/pages/Offers/List/index.tsx b/frontend/src/pages/Offers/List/index.tsx new file mode 100644 index 0000000000..472c3ea9a5 --- /dev/null +++ b/frontend/src/pages/Offers/List/index.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +import type { SelectCSDProps } from 'components'; +import { Box, Cards, Header, PropertyFilter, SelectCSD } from 'components'; + +import { useCollection } from 'hooks'; +import { useProjectFilter } from 'hooks/useProjectFilter'; +import { useGetGpusListQuery } from 'services/gpu'; + +import { useEmptyMessages } from './hooks/useEmptyMessages'; +import { useFilters } from './hooks/useFilters'; +import { bytesFormatter } from '../../Runs/Details/Jobs/Metrics/helpers'; +import { renderRange, stringRangeToObject } from './helpers'; + +import styles from './styles.module.scss'; + +const getRequestParams = ({ + gpu_name, + backend, + gpu_count, + gpu_memory, +}: { + gpu_name?: string[]; + backend?: string[]; + gpu_count?: string; + gpu_memory?: string; +}): Omit => { + const gpuCountMinMax = stringRangeToObject(gpu_count ?? ''); + const gpuMemoryMinMax = stringRangeToObject(gpu_memory ?? ''); + + return { + run_spec: { + configuration: { + nodes: 1, + ports: [], + commands: [':'], + type: 'task', + privileged: false, + home_dir: '/root', + env: {}, + resources: { + cpu: { min: 2 }, + memory: { min: 8.0 }, + disk: { size: { min: 100.0 } }, + gpu: { + ...(gpu_name?.length ? { name: gpu_name } : {}), + ...(gpuCountMinMax ? { count: gpuCountMinMax } : {}), + ...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax } : {}), + }, + }, + volumes: [], + files: [], + setup: [], + ...(backend?.length ? { backends: backend } : {}), + }, + profile: { name: 'default', default: false }, + ssh_key_pub: '(dummy)', + }, + }; +}; + +export const OfferList = () => { + const { t } = useTranslation(); + const [requestParams, setRequestParams] = useState | undefined>(); + const [searchParams, setSearchParams] = useSearchParams(); + + const { projectOptions, selectedProject, setSelectedProject } = useProjectFilter({ + localStorePrefix: 'offers-list-projects', + }); + + console.log({ requestParams }); + const { data, isLoading, isFetching } = useGetGpusListQuery( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + { + project_name: selectedProject?.value ?? '', + ...requestParams, + }, + { + skip: !selectedProject || !requestParams, + }, + ); + + const { + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + } = useFilters({ gpus: data?.gpus ?? [] }); + + console.log({ filteringRequestParams }); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + setRequestParams(getRequestParams(filteringRequestParams)); + }, [JSON.stringify(filteringRequestParams)]); + + const onChangeProjectName = (project: SelectCSDProps.Option) => { + setSelectedProject(project); + setSearchParams({ project_name: project.value ?? '' }); + }; + + useEffect(() => { + if (!selectedProject && projectOptions?.length) { + const searchParamProjectName = searchParams.get('project_name'); + + onChangeProjectName(projectOptions.find((p) => p.value === searchParamProjectName) ?? projectOptions[0]); + } + }, [projectOptions]); + + const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter }); + + const { items, collectionProps } = useCollection(data?.gpus ?? [], { + filtering: { + empty: renderEmptyMessage(), + noMatch: renderNoMatchMessage(), + }, + selection: {}, + }); + + return ( + gpu.name, + sections: [ + { + id: 'backend', + header: t('offer.backend'), + content: (gpu) => gpu.backend ?? gpu.backends?.join(', ') ?? '-', + width: 50, + }, + { + id: 'region', + header: t('offer.region'), + content: (gpu) => gpu.region ?? gpu.regions?.join(', ') ?? '-', + width: 50, + }, + { + id: 'count', + header: t('offer.count'), + content: (gpu) => renderRange(gpu.count) ?? '-', + width: 50, + }, + { + id: 'price', + header: t('offer.price'), + content: (gpu) => renderRange(gpu.price) ?? '-', + width: 50, + }, + { + id: 'memory_mib', + header: t('offer.memory_mib'), + content: (gpu) => bytesFormatter(gpu.memory_mib), + width: 50, + }, + ], + }} + loading={isLoading || isFetching} + loadingText={t('common.loading')} + stickyHeader={true} + header={
{t('offer.title')}
} + filter={ +
+
+ {t('offer.project_name_label')}: +
+ onChangeProjectName(selectedOption)} + /> +
+
+ +
+ +
+
+ } + /> + ); +}; diff --git a/frontend/src/pages/Offers/List/styles.module.scss b/frontend/src/pages/Offers/List/styles.module.scss new file mode 100644 index 0000000000..bb05317343 --- /dev/null +++ b/frontend/src/pages/Offers/List/styles.module.scss @@ -0,0 +1,22 @@ +.selectFilters { + display: flex; + flex-wrap: wrap; + gap: 0 20px; + + .filterField { + display: flex; + align-items: center; + gap: 8px; + } + + .selectFilter { + flex-shrink: 0; + width: 240px; + } + + .propertyFilter { + max-width: 640px; + flex-grow: 1; + min-width: 0; + } +} \ No newline at end of file diff --git a/frontend/src/pages/Offers/index.ts b/frontend/src/pages/Offers/index.ts new file mode 100644 index 0000000000..f49062bea0 --- /dev/null +++ b/frontend/src/pages/Offers/index.ts @@ -0,0 +1 @@ +export { OfferList } from './List'; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index cf283d728a..809b086884 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -23,6 +23,7 @@ import { CreditsHistoryAdd, UserAdd, UserDetails, UserEdit, UserList } from 'pag import { UserBilling, UserProjects, UserSettings } from 'pages/User/Details'; import { AuthErrorMessage } from './App/AuthErrorMessage'; +import { OfferList } from './pages/Offers'; import { JobDetails } from './pages/Runs/Details/Jobs/Details/JobDetails'; import { VolumeList } from './pages/Volumes'; import { ROUTES } from './routes'; @@ -136,6 +137,12 @@ export const router = createBrowserRouter([ element: , }, + // Offers + { + path: ROUTES.OFFERS.LIST, + element: , + }, + // Models { path: ROUTES.MODELS.LIST, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index f529570736..71f77066c0 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -98,6 +98,10 @@ export const ROUTES = { LIST: '/runs', }, + OFFERS: { + LIST: '/offers', + }, + MODELS: { LIST: '/models', DETAILS: { diff --git a/frontend/src/services/gpu.ts b/frontend/src/services/gpu.ts new file mode 100644 index 0000000000..ecb5a60a74 --- /dev/null +++ b/frontend/src/services/gpu.ts @@ -0,0 +1,29 @@ +import { API } from 'api'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; + +export const gpuApi = createApi({ + reducerPath: 'gpuApi', + baseQuery: fetchBaseQuery({ + prepareHeaders: fetchBaseQueryHeaders, + }), + + tagTypes: ['Gpus'], + + endpoints: (builder) => ({ + getGpusList: builder.query({ + query: ({ project_name, ...body }) => { + return { + url: API.PROJECTS.GPUS_LIST(project_name), + method: 'POST', + body, + }; + }, + + providesTags: ['Gpus'], + }), + }), +}); + +export const { useGetGpusListQuery } = gpuApi; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 7d6afb8cf5..326f113add 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -17,6 +17,8 @@ import { volumeApi } from 'services/volume'; import appReducer from 'App/slice'; +import { gpuApi } from './services/gpu'; + export const store = configureStore({ reducer: { app: appReducer, @@ -32,6 +34,7 @@ export const store = configureStore({ [serverApi.reducerPath]: serverApi.reducer, [volumeApi.reducerPath]: volumeApi.reducer, [secretApi.reducerPath]: secretApi.reducer, + [gpuApi.reducerPath]: gpuApi.reducer, [mainApi.reducerPath]: mainApi.reducer, }, @@ -50,6 +53,7 @@ export const store = configureStore({ .concat(serverApi.middleware) .concat(volumeApi.middleware) .concat(secretApi.middleware) + .concat(gpuApi.middleware) .concat(mainApi.middleware), }); diff --git a/frontend/src/types/backend.d.ts b/frontend/src/types/backend.d.ts index 470063d847..b41d796cee 100644 --- a/frontend/src/types/backend.d.ts +++ b/frontend/src/types/backend.d.ts @@ -1,42 +1,46 @@ -declare type TBackendType = | "aws" - |"azure" - |"cudo" - |"datacrunch" - |"dstack" - |"gcp" - |"kubernetes" - |"lambda" - |"local" - |"nebius" - |"remote" - |"oci" - |"runpod" - |"tensordock" - |"vastai"; +declare type TBackendType = + | 'aws' + | 'azure' + | 'cudo' + | 'datacrunch' + | 'dstack' + | 'gcp' + | 'kubernetes' + | 'lambda' + | 'local' + | 'nebius' + | 'remote' + | 'oci' + | 'runpod' + | 'tensordock' + | 'vastai' + | 'cloudrift' + | 'hotaisle' + | 'vultr'; -declare type TBackendValueField = { - selected?: TSelectedType, - values: TValuesType +declare type TBackendValueField = { + selected?: TSelectedType; + values: TValuesType; } | null; -declare type TBackendValuesResponse = IAwsBackendValues & IAzureBackendValues & IGCPBackendValues & ILambdaBackendValues +declare type TBackendValuesResponse = IAwsBackendValues & IAzureBackendValues & IGCPBackendValues & ILambdaBackendValues; declare interface IBackendLocal { - type: 'local', - path: string + type: 'local'; + path: string; } declare interface IBackendDstack { - type: 'dstack', - path: string + type: 'dstack'; + path: string; } -declare type TBackendConfig = IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack +declare type TBackendConfig = IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack; declare interface IBackendConfigYaml { - config_yaml: string, + config_yaml: string; } declare interface IProjectBackend { - name: string - config: TBackendConfig + name: string; + config: TBackendConfig; } diff --git a/frontend/src/types/gpu.ts b/frontend/src/types/gpu.ts new file mode 100644 index 0000000000..d46dac4447 --- /dev/null +++ b/frontend/src/types/gpu.ts @@ -0,0 +1,92 @@ +declare type TAvailability = 'unknown' | 'available' | 'not_available' | 'no_quota' | 'no_balance' | 'idle' | 'busy'; + +declare type TSpot = 'spot' | 'on-demand' | 'auto'; + +declare type TRange = { + min: number; + max?: number; +}; + +declare interface IPortMappingRequest { + local_port?: number; + container_port: number; +} + +declare interface IGPUSpecRequest { + vendor?: string; + name?: string[]; + count?: TRange | number | string; + memory?: TRange | number | string; + total_memory?: TRange | number | string; + compute_capability?: any[]; +} + +declare interface IResourcesSpecRequest { + cpu?: TRange | number | string; + memory?: TRange | number | string; + shm_size?: number | string; + gpu?: IGPUSpecRequest | number | string; + disk?: { size: TRange | number | string } | number | string; +} + +declare interface ITaskConfigurationQueryParams { + nodes?: number; + ports?: Array; + commands?: string[]; + type: 'task'; + resources?: IResourcesSpecRequest; + privileged?: boolean; + home_dir?: string; + env?: string[] | object; + volumes?: Array; + docker?: boolean; + files?: Array; + setup?: string[]; + backends?: TBackendType[]; + regions?: string[]; + availability_zones?: string[]; + instance_types?: string[]; + reservation?: string; + spot_policy?: TSpot; + max_price?: number; +} + +declare interface IGpu { + name: string; + memory_mib: number; + vendor: string; + availability: TAvailability[]; + spot: TSpot[]; + count: { + min: number; + max: number; + }; + price: { + min: number; + max: number; + }; + backends?: TBackendType[]; + backend?: TBackendType; + regions?: string[]; + region?: string; +} + +declare type TGpusListQueryParams = { + project_name: string; + run_spec: { + group_gy?: string; + spot?: string | boolean; + gpu_vendor?: string; + gpu_count?: number; + gpu_memory?: number; + gpu_name?: string; + backends?: TBackendType[]; + configuration: ITaskConfigurationQueryParams; + profile?: { name: string; default?: boolean }; + ssh_key_pub: string; + }; +}; + +declare type TGpusListQueryResponse = { + gpus: IGpu[]; +}; From aa1d1b910e623fc3223f840b0acd48aa211eda05 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 1 Sep 2025 00:10:03 +0300 Subject: [PATCH 2/3] [Feature]: UI for offers #3004 --- frontend/src/libs/filters.ts | 28 +++- frontend/src/locale/en.json | 11 +- frontend/src/pages/Offers/List/helpers.ts | 42 ++++-- .../Offers/List/hooks/useEmptyMessages.tsx | 11 ++ .../src/pages/Offers/List/hooks/useFilters.ts | 101 +++++++++++---- frontend/src/pages/Offers/List/index.tsx | 122 +++++++++--------- .../src/pages/Offers/List/styles.module.scss | 7 +- 7 files changed, 208 insertions(+), 114 deletions(-) diff --git a/frontend/src/libs/filters.ts b/frontend/src/libs/filters.ts index 425caaaef8..d3f06c98df 100644 --- a/frontend/src/libs/filters.ts +++ b/frontend/src/libs/filters.ts @@ -19,6 +19,22 @@ export const tokensToSearchParams = ( return params; }; +export type RequestParam = string | { min: number } | { max: number }; + +const convertTokenValueToRequestParam = (token: PropertyFilterProps.Query['tokens'][number]): RequestParam => { + const { value, operator } = token; + + if (operator === '>=') { + return { min: Number(value) }; + } + + if (operator === '<=') { + return { max: Number(value) }; + } + + return value; +}; + export const tokensToRequestParams = ({ tokens, arrayFieldKeys, @@ -26,7 +42,7 @@ export const tokensToRequestParams = ({ tokens: PropertyFilterProps.Query['tokens']; arrayFieldKeys?: RequestParamsKeys[]; }) => { - return tokens.reduce>( + return tokens.reduce>( (acc, token) => { const propertyKey = token.propertyKey as RequestParamsKeys; @@ -34,21 +50,23 @@ export const tokensToRequestParams = ({ return acc; } + const convertedValue = convertTokenValueToRequestParam(token); + if (arrayFieldKeys?.includes(propertyKey)) { if (Array.isArray(acc[propertyKey])) { - acc[propertyKey].push(token.value); + acc[propertyKey].push(convertedValue as string); } else { - acc[propertyKey] = [token.value]; + acc[propertyKey] = [convertedValue as string]; } return acc; } - acc[propertyKey] = token.value; + acc[propertyKey] = convertedValue; return acc; }, - {} as Record, + {} as Record, ); }; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 6c25f24520..523f1febca 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -449,13 +449,18 @@ }, "offer": { "title": "Offers", - "project_name_label": "Project name", "filter_property_placeholder": "Filter offers by properties", "backend": "Backend", + "backend_plural": "Backends", + "availability": "Availability", + "groupBy": "Group By", "region": "Region", "count": "Count", - "price": "Price", - "memory_mib": "Memmory", + "price": "$/GPU", + "memory_mib": "Memory", + "spot": "Spot", + "empty_message_title_select_project": "Need project name", + "empty_message_text_select_project": "Please choose project name in property filter", "empty_message_title": "No offers", "empty_message_text": "No offers to display.", "nomatch_message_title": "No matches", diff --git a/frontend/src/pages/Offers/List/helpers.ts b/frontend/src/pages/Offers/List/helpers.ts index 9af2465429..42891b11d9 100644 --- a/frontend/src/pages/Offers/List/helpers.ts +++ b/frontend/src/pages/Offers/List/helpers.ts @@ -1,5 +1,13 @@ +import { RequestParam } from '../../../libs/filters'; + const rangeSeparator = '..'; +export function convertMiBToGB(mib: number) { + const bytes = mib * Math.pow(2, 20); // Convert MiB to bytes + // Convert bytes to GB + return bytes / Math.pow(10, 9); +} + export const getPropertyFilterOptions = (gpus: IGpu[]) => { const names = new Set(); const backends = new Set(); @@ -30,7 +38,7 @@ export const getPropertyFilterOptions = (gpus: IGpu[]) => { }; }; -const round = (number: number) => Math.round(number * 100) / 100; +export const round = (number: number) => Math.round(number * 100) / 100; export const renderRange = (range: { min?: number; max?: number }) => { if (typeof range.min === 'number' && typeof range.max === 'number' && range.max != range.min) { @@ -40,23 +48,29 @@ export const renderRange = (range: { min?: number; max?: number }) => { return range.min?.toString() ?? range.max?.toString(); }; -export const stringRangeToObject = (rangeString: string): { min?: number; max?: number } | undefined => { - if (!rangeString) return; +export const rangeToObject = (range: RequestParam): { min?: number; max?: number } | undefined => { + if (!range) return; - const [minString, maxString] = rangeString.split(rangeSeparator); + if (typeof range === 'string') { + const [minString, maxString] = range.split(rangeSeparator); - const min = Number(minString); - const max = Number(maxString); + const min = Number(minString); + const max = Number(maxString); - if (!isNaN(min) && !isNaN(max)) { - return { min, max }; - } + if (!isNaN(min) && !isNaN(max)) { + return { min, max }; + } - if (!isNaN(min)) { - return { min, max: min }; - } + if (!isNaN(min)) { + return { min, max: min }; + } - if (!isNaN(max)) { - return { min: max, max }; + if (!isNaN(max)) { + return { min: max, max }; + } } + + // 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 9a010fb65e..f978b8bc69 100644 --- a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx +++ b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx @@ -6,13 +6,24 @@ import { Button, ListEmptyMessage } from 'components'; export const useEmptyMessages = ({ clearFilter, isDisabledClearFilter, + projectNameSelected, }: { clearFilter?: () => void; isDisabledClearFilter?: boolean; + projectNameSelected?: boolean; }) => { const { t } = useTranslation(); const renderEmptyMessage = useCallback<() => React.ReactNode>(() => { + if (!projectNameSelected) { + return ( + + ); + } + return (