From 87cdbdd124af0f0cc8911332ff4327ecf9600a2c Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 27 Jan 2025 22:37:52 +0300 Subject: [PATCH 1/7] [UI] New UI for fleets and instances #2133 --- frontend/src/api.ts | 7 + frontend/src/layouts/AppLayout/hooks.ts | 1 + frontend/src/locale/en.json | 8 +- .../pages/Fleets/Details/Instances/index.tsx | 52 ------ frontend/src/pages/Fleets/Details/index.tsx | 84 ++++++++- frontend/src/pages/Fleets/List/hooks.tsx | 17 +- frontend/src/pages/Fleets/List/index.tsx | 17 +- .../pages/Instances/List/hooks/useActions.ts | 55 ++++++ .../List/hooks/useColumnDefinitions.tsx} | 46 +++-- .../Instances/List/hooks/useEmptyMessage.tsx | 42 +++++ .../pages/Instances/List/hooks/useFilters.ts | 55 ++++++ .../List/hooks/useInstanceListData.ts | 89 ++++++++++ frontend/src/pages/Instances/List/index.tsx | 163 ++++++++++++++++++ .../pages/Instances/List/styles.module.scss | 20 +++ frontend/src/pages/Instances/index.ts | 1 + frontend/src/router.tsx | 7 + frontend/src/routes.ts | 4 + frontend/src/services/instance.ts | 45 +++++ frontend/src/store.ts | 3 + frontend/src/types/fleet.d.ts | 33 +--- frontend/src/types/global.d.ts | 7 + frontend/src/types/instance.d.ts | 36 ++++ frontend/webpack/dev.js | 9 +- 23 files changed, 679 insertions(+), 122 deletions(-) delete mode 100644 frontend/src/pages/Fleets/Details/Instances/index.tsx create mode 100644 frontend/src/pages/Instances/List/hooks/useActions.ts rename frontend/src/pages/{Fleets/Details/Instances/hooks.tsx => Instances/List/hooks/useColumnDefinitions.tsx} (70%) create mode 100644 frontend/src/pages/Instances/List/hooks/useEmptyMessage.tsx create mode 100644 frontend/src/pages/Instances/List/hooks/useFilters.ts create mode 100644 frontend/src/pages/Instances/List/hooks/useInstanceListData.ts create mode 100644 frontend/src/pages/Instances/List/index.tsx create mode 100644 frontend/src/pages/Instances/List/styles.module.scss create mode 100644 frontend/src/pages/Instances/index.ts create mode 100644 frontend/src/services/instance.ts create mode 100644 frontend/src/types/instance.d.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fea4e16ad..c77d08069 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -74,6 +74,8 @@ export const API = { FLEETS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/list`, FLEETS_DETAILS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/get`, FLEETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/delete`, + FLEET_INSTANCES_DELETE: (projectName: IProject['project_name']) => + `${API.BASE()}/project/${projectName}/fleets/delete_instances`, // Fleets VOLUMES_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/volumes/delete`, @@ -123,6 +125,11 @@ export const API = { LIST: () => `${API.FLEETS.BASE()}/list`, }, + INSTANCES: { + BASE: () => `${API.BASE()}/instances`, + LIST: () => `${API.INSTANCES.BASE()}/list`, + }, + SERVER: { BASE: () => `${API.BASE()}/server`, INFO: () => `${API.SERVER.BASE()}/get_info`, diff --git a/frontend/src/layouts/AppLayout/hooks.ts b/frontend/src/layouts/AppLayout/hooks.ts index acf638be1..c8bfdb8c0 100644 --- a/frontend/src/layouts/AppLayout/hooks.ts +++ b/frontend/src/layouts/AppLayout/hooks.ts @@ -27,6 +27,7 @@ export const useSideNavigation = () => { { type: 'link', text: t('navigation.runs'), href: ROUTES.RUNS.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 }, { type: 'link', text: t('navigation.volumes'), href: ROUTES.VOLUMES.LIST }, { type: 'link', text: t('navigation.project_other'), href: ROUTES.PROJECT.LIST }, diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index d223c265d..a0531f84b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -73,7 +73,8 @@ "account": "User", "billing": "Billing", "resources": "Resources", - "volumes": "Volumes" + "volumes": "Volumes", + "instances": "Instances" }, "backend": { @@ -443,10 +444,15 @@ "title": "Instances", "empty_message_title": "No instances", "empty_message_text": "No instances to display.", + "nomatch_message_title": "No matches", + "nomatch_message_text": "We can't find a match.", "instance_name": "Instance", + "instance_num": "Instance num", "created": "Created", "status": "Status", "project": "Project", + "hostname": "Host name", + "instance_type": "Type", "statuses": { "pending": "Pending", "provisioning": "Provisioning", diff --git a/frontend/src/pages/Fleets/Details/Instances/index.tsx b/frontend/src/pages/Fleets/Details/Instances/index.tsx deleted file mode 100644 index 4e24299bf..000000000 --- a/frontend/src/pages/Fleets/Details/Instances/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Header, ListEmptyMessage, Table } from 'components'; - -import { useCollection } from 'hooks'; - -import { useColumnsDefinitions } from './hooks'; - -export interface Props { - data: IInstance[]; -} - -export const Instances: React.FC = ({ data }) => { - const { t } = useTranslation(); - - const { columns } = useColumnsDefinitions(); - - const renderEmptyMessage = (): React.ReactNode => { - return ( - - ); - }; - - const { items, collectionProps } = useCollection(data ?? [], { - filtering: { - empty: renderEmptyMessage(), - noMatch: renderEmptyMessage(), - }, - selection: {}, - }); - - const renderCounter = () => { - if (!data?.length) return ''; - - return `(${data.length})`; - }; - - return ( - {t('fleets.instances.title')}} - /> - ); -}; diff --git a/frontend/src/pages/Fleets/Details/index.tsx b/frontend/src/pages/Fleets/Details/index.tsx index cb70563f2..1f012cbee 100644 --- a/frontend/src/pages/Fleets/Details/index.tsx +++ b/frontend/src/pages/Fleets/Details/index.tsx @@ -1,15 +1,27 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import { format } from 'date-fns'; -import { Container, ContentLayout, DetailsHeader, Loader } from 'components'; +import { + Box, + ColumnLayout, + Container, + ContentLayout, + DetailsHeader, + Header, + Icon, + Loader, + NavigateLink, + StatusIndicator, +} from 'components'; +import { DATE_TIME_FORMAT } from 'consts'; import { useBreadcrumbs } from 'hooks'; +import { getFleetStatusIconType } from 'libs/fleet'; import { ROUTES } from 'routes'; import { useGetFleetDetailsQuery } from 'services/fleet'; -import { Instances } from './Instances'; - export const FleetDetails: React.FC = () => { const { t } = useTranslation(); const params = useParams(); @@ -48,7 +60,71 @@ export const FleetDetails: React.FC = () => { )} - {data && } + {data && ( + {t('common.general')}}> + +
+ {t('fleets.fleet')} +
{data.name}
+
+ +
+ {t('fleets.instances.status')} + +
+ + {t(`fleets.statuses.${data.status}`)} + +
+
+ +
+ {t('fleets.instances.project')} + +
+ + {data.project_name} + +
+
+ +
+ {t('fleets.instances.backend')} +
{data.spec.configuration?.backends?.join(', ')}
+
+ +
+ {t('fleets.instances.region')} +
{data.spec.configuration.regions?.join(', ')}
+
+ +
+ {t('fleets.instances.region')} +
{data.spec.configuration?.spot_policy === 'spot' && }
+
+ +
+ {t('fleets.instances.started')} +
{format(new Date(data.created_at), DATE_TIME_FORMAT)}
+
+ +
+ {t('fleets.instances.price')} +
{data.spec.configuration?.max_price && `$${data.spec.configuration?.max_price}`}
+
+ +
+ {t('fleets.instances.title')} + +
+ + Show fleet's instances + +
+
+
+
+ )} ); }; diff --git a/frontend/src/pages/Fleets/List/hooks.tsx b/frontend/src/pages/Fleets/List/hooks.tsx index b2365a03a..01e101337 100644 --- a/frontend/src/pages/Fleets/List/hooks.tsx +++ b/frontend/src/pages/Fleets/List/hooks.tsx @@ -8,11 +8,10 @@ import { SelectCSDProps } from 'components'; import { DATE_TIME_FORMAT, DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { getFleetStatusIconType } from 'libs/fleet'; +import { ROUTES } from 'routes'; import { useLazyGetFleetsQuery } from 'services/fleet'; import { useGetProjectsQuery } from 'services/project'; -import { ROUTES } from '../../../routes'; - export const useEmptyMessages = ({ clearFilters, isDisabledClearFilter, @@ -50,9 +49,11 @@ export const useColumnsDefinitions = () => { const columns: TableProps.ColumnDefinition[] = [ { - id: 'instance_name', + id: 'fleet_name', header: t('fleets.fleet'), - cell: (item) => item.name, + cell: (item) => ( + {item.name} + ), }, { id: 'status', @@ -106,7 +107,8 @@ export const useColumnsDefinitions = () => { }; export const useFilters = () => { - const [onlyActive, setOnlyActive] = useLocalStorageState('administration-fleet-list-is-active', false); + // const [onlyActive, setOnlyActive] = useLocalStorageState('fleet-list-is-active', false); + const [onlyActive, setOnlyActive] = useState(true); const [selectedProject, setSelectedProject] = useState(null); const { data: projectsData } = useGetProjectsQuery(); @@ -118,11 +120,12 @@ export const useFilters = () => { }, [projectsData]); const clearFilters = () => { - setOnlyActive(false); + // setOnlyActive(false); setSelectedProject(null); }; - const isDisabledClearFilter = !selectedProject && !onlyActive; + // const isDisabledClearFilter = !selectedProject && !onlyActive; + const isDisabledClearFilter = !selectedProject; return { projectOptions, diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index 871e3bd4f..ec359a211 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -61,12 +61,6 @@ export const FleetList: React.FC = () => { deleteFleets([...selectedItems]).catch(console.log); }; - const renderCounter = () => { - if (!data?.length) return ''; - - return `(${data.length})`; - }; - return (
{ header={
+ + ); + }, [clearFilters, isDisabledClearFilter]); + + const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => { + return ( + + + + ); + }, [clearFilters, isDisabledClearFilter]); + + return { renderEmptyMessage, renderNoMatchMessage } as const; +}; diff --git a/frontend/src/pages/Instances/List/hooks/useFilters.ts b/frontend/src/pages/Instances/List/hooks/useFilters.ts new file mode 100644 index 000000000..47b79fd30 --- /dev/null +++ b/frontend/src/pages/Instances/List/hooks/useFilters.ts @@ -0,0 +1,55 @@ +import { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { SelectCSDProps } from 'components'; + +import { useLocalStorageState } from 'hooks/useLocalStorageState'; +import { useGetProjectsQuery } from 'services/project'; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const [onlyActive, setOnlyActive] = useLocalStorageState('instances-list-is-active', false); + const [selectedProject, setSelectedProject] = useState(null); + + const { data: projectsData } = useGetProjectsQuery(); + + const projectOptions = useMemo(() => { + if (!projectsData?.length) return []; + + return projectsData.map((project) => ({ label: project.project_name, value: project.project_name })); + }, [projectsData]); + + const clearFilters = () => { + setOnlyActive(false); + setSelectedProject(null); + + setSearchParams((prev) => { + prev.delete('fleetId'); + return prev; + }); + }; + + const selectedFleet = useMemo(() => { + const fleetName = searchParams.get('fleetId'); + + if (fleetName) { + return { label: fleetName, value: fleetName }; + } + + return null; + }, [searchParams]); + + const isDisabledClearFilter = !selectedProject && !onlyActive && !selectedFleet; + + return { + projectOptions, + selectedProject, + setSelectedProject, + selectedFleet, + onlyActive, + setOnlyActive, + clearFilters, + isDisabledClearFilter, + } as const; +}; diff --git a/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts b/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts new file mode 100644 index 000000000..fabdb484b --- /dev/null +++ b/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react'; + +import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useLazyGetInstancesQuery } from 'services/instance'; + +export const useInstanceListData = ({ project_names, only_active, fleet_ids }: TInstanceListRequestParams) => { + const [data, setData] = useState([]); + const [pagesCount, setPagesCount] = useState(1); + const [disabledNext, setDisabledNext] = useState(false); + const lastRequestParams = useRef(undefined); + + const [getInstances, { isLoading, isFetching }] = useLazyGetInstancesQuery(); + + const getInstancesRequest = (params?: TInstanceListRequestParams) => { + lastRequestParams.current = params; + + return getInstances({ + project_names, + fleet_ids, + only_active, + limit: DEFAULT_TABLE_PAGE_SIZE, + ...params, + }).unwrap(); + }; + + const refreshList = () => { + getInstancesRequest(lastRequestParams.current).then((result) => { + setDisabledNext(false); + setData(result); + }); + }; + + useEffect(() => { + getInstancesRequest().then((result) => { + setPagesCount(1); + setDisabledNext(false); + setData(result); + }); + }, [project_names, only_active, fleet_ids]); + + const nextPage = async () => { + if (data.length === 0 || disabledNext) { + return; + } + + try { + const result = await getInstancesRequest({ + prev_created_at: data[data.length - 1].created, + prev_id: data[data.length - 1].id, + }); + + if (result.length > 0) { + setPagesCount((count) => count + 1); + setData(result); + } else { + setDisabledNext(true); + } + } catch (e) { + console.log(e); + } + }; + + const prevPage = async () => { + if (pagesCount === 1) { + return; + } + + try { + const result = await getInstancesRequest({ + prev_created_at: data[0].created, + prev_id: data[0].id, + ascending: true, + }); + + setDisabledNext(false); + + if (result.length > 0) { + setPagesCount((count) => count - 1); + setData(result); + } else { + setPagesCount(1); + } + } catch (e) { + console.log(e); + } + }; + + return { data, pagesCount, disabledNext, isLoading: isLoading || isFetching, nextPage, prevPage, refreshList }; +}; diff --git a/frontend/src/pages/Instances/List/index.tsx b/frontend/src/pages/Instances/List/index.tsx new file mode 100644 index 000000000..63c3a6129 --- /dev/null +++ b/frontend/src/pages/Instances/List/index.tsx @@ -0,0 +1,163 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, FormField, Header, Pagination, SelectCSD, SpaceBetween, Table, Toggle } from 'components'; + +import { useBreadcrumbs } from 'hooks'; +import { useCollection } from 'hooks'; +import { ROUTES } from 'routes'; + +import { useActions } from './hooks/useActions'; +import { useColumnsDefinitions } from './hooks/useColumnDefinitions'; +import { useEmptyMessages } from './hooks/useEmptyMessage'; +import { useFilters } from './hooks/useFilters'; +import { useInstanceListData } from './hooks/useInstanceListData'; + +import styles from './styles.module.scss'; + +export const List: React.FC = () => { + const { t } = useTranslation(); + + useBreadcrumbs([ + { + text: t('navigation.instances'), + href: ROUTES.INSTANCES.LIST, + }, + ]); + + const { columns } = useColumnsDefinitions(); + + const { + onlyActive, + setOnlyActive, + isDisabledClearFilter, + clearFilters, + projectOptions, + selectedProject, + setSelectedProject, + selectedFleet, + } = useFilters(); + + const params = useMemo(() => { + return { + project_names: selectedProject?.value ? [selectedProject.value] : undefined, + only_active: onlyActive, + fleet_ids: selectedFleet?.value ? [selectedFleet.value] : undefined, + }; + }, [selectedProject, selectedFleet, onlyActive]); + + const { data, pagesCount, disabledNext, isLoading, nextPage, prevPage, refreshList } = useInstanceListData(params); + + const isDisabledPagination = isLoading || data.length === 0; + + const { deleteFleets, isDeleting } = useActions(); + + const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilters, isDisabledClearFilter }); + + const { items, collectionProps } = useCollection(data, { + filtering: { + empty: renderEmptyMessage(), + noMatch: renderNoMatchMessage(), + }, + pagination: { pageSize: 20 }, + selection: {}, + }); + + const { selectedItems } = collectionProps; + + const isDisabledDeleteButton = !selectedItems?.length || isDeleting; + + const deleteClickHandle = () => { + if (!selectedItems?.length) return; + + deleteFleets([...selectedItems]).catch(console.log); + }; + + return ( +
+ + + + + + } + pagination={ + + } + /> + ); +}; diff --git a/frontend/src/pages/Instances/List/styles.module.scss b/frontend/src/pages/Instances/List/styles.module.scss new file mode 100644 index 000000000..a6917fc62 --- /dev/null +++ b/frontend/src/pages/Instances/List/styles.module.scss @@ -0,0 +1,20 @@ +.filters { + --select-width: calc((688px - 3 * 20px) / 2); + display: flex; + flex-wrap: wrap; + gap: 0 20px; + + .select { + width: var(--select-width, 30%); + } + + .activeOnly { + display: flex; + align-items: center; + padding-top: 26px; + } + + .clear { + padding-top: 26px; + } +} diff --git a/frontend/src/pages/Instances/index.ts b/frontend/src/pages/Instances/index.ts new file mode 100644 index 000000000..ee1bcdcc2 --- /dev/null +++ b/frontend/src/pages/Instances/index.ts @@ -0,0 +1 @@ +export { List as InstanceList } from './List'; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 7660e7190..806b93913 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -9,6 +9,7 @@ import { LoginByOktaCallback } from 'App/Login/LoginByOktaCallback'; import { TokenLogin } from 'App/Login/TokenLogin'; import { Logout } from 'App/Logout'; import { FleetDetails, FleetList } from 'pages/Fleets'; +import { InstanceList } from 'pages/Instances'; import { ModelsList } from 'pages/Models'; import { ModelDetails } from 'pages/Models/Details'; import { ProjectAdd, ProjectDetails, ProjectList, ProjectSettings } from 'pages/Project'; @@ -121,6 +122,12 @@ export const router = createBrowserRouter([ element: , }, + // Instances + { + path: ROUTES.INSTANCES.LIST, + element: , + }, + // Volumes { path: ROUTES.VOLUMES.LIST, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 4e0b7b333..07065aee4 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -86,6 +86,10 @@ export const ROUTES = { }, }, + INSTANCES: { + LIST: '/instances', + }, + VOLUMES: { LIST: '/volumes', }, diff --git a/frontend/src/services/instance.ts b/frontend/src/services/instance.ts new file mode 100644 index 000000000..e483084b1 --- /dev/null +++ b/frontend/src/services/instance.ts @@ -0,0 +1,45 @@ +import { API } from 'api'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; + +export const instanceApi = createApi({ + reducerPath: 'instanceApi', + baseQuery: fetchBaseQuery({ + prepareHeaders: fetchBaseQueryHeaders, + }), + + tagTypes: ['Instance', 'Instances'], + + endpoints: (builder) => ({ + getInstances: builder.query({ + query: (body) => { + return { + url: API.INSTANCES.LIST(), + method: 'POST', + body, + }; + }, + + providesTags: (result) => + result ? [...result.map(({ name }) => ({ type: 'Instance' as const, id: name })), 'Instances'] : ['Instances'], + }), + + deleteInstances: builder.mutation< + void, + { projectName: IProject['project_name']; fleetName: string; instancesNums: number[] } + >({ + query: ({ projectName, fleetName, instancesNums }) => { + return { + url: API.PROJECTS.FLEET_INSTANCES_DELETE(projectName), + method: 'POST', + body: { name: fleetName, instance_nums: instancesNums }, + }; + }, + + invalidatesTags: ['Instances'], + }), + }), +}); + +export const { useLazyGetInstancesQuery, useDeleteInstancesMutation } = instanceApi; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 5b73510dd..d57b01a7f 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -6,6 +6,7 @@ import { artifactApi } from 'services/artifact'; import { authApi } from 'services/auth'; import { fleetApi } from 'services/fleet'; import { gatewayApi } from 'services/gateway'; +import { instanceApi } from 'services/instance'; import { mainApi } from 'services/mainApi'; import { projectApi } from 'services/project'; import { runApi } from 'services/run'; @@ -23,6 +24,7 @@ export const store = configureStore({ [runApi.reducerPath]: runApi.reducer, [artifactApi.reducerPath]: artifactApi.reducer, [fleetApi.reducerPath]: fleetApi.reducer, + [instanceApi.reducerPath]: instanceApi.reducer, [userApi.reducerPath]: userApi.reducer, [gatewayApi.reducerPath]: gatewayApi.reducer, [authApi.reducerPath]: authApi.reducer, @@ -39,6 +41,7 @@ export const store = configureStore({ .concat(runApi.middleware) .concat(artifactApi.middleware) .concat(fleetApi.middleware) + .concat(instanceApi.middleware) .concat(gatewayApi.middleware) .concat(userApi.middleware) .concat(authApi.middleware) diff --git a/frontend/src/types/fleet.d.ts b/frontend/src/types/fleet.d.ts index 6c48a2cac..d7f3275fd 100644 --- a/frontend/src/types/fleet.d.ts +++ b/frontend/src/types/fleet.d.ts @@ -1,39 +1,8 @@ declare type TSpotPolicy = "spot" | "on-demand" | "auto"; -declare type TInstanceStatus = - | 'pending' - | 'creating' - | 'starting' - | 'provisioning' - | 'idle' - | 'busy' - | 'terminating' - | 'terminated'; - -declare interface IInstance { - backend: TBackendType, - instance_type: { - name: string, - resources: IResources - }, - name: string, - job_name: string | null, - project_name: string | null, - job_status: TJobStatus | null, - hostname: string, - status: TInstanceStatus, - created: string, - region: string, - price: number | null -} - -declare type TFleetListRequestParams = { +declare type TFleetListRequestParams = IBaseRequestListParams & { project_name?: string, only_active?: boolean, - prev_created_at?: string, - prev_id?: string, - limit?: number, - ascending?: boolean, } declare interface ISSHHostParamsRequest { diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts index 216b2892a..4bc4d3abd 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -9,6 +9,13 @@ declare type AddedEmptyString = { declare type DateTime = string +declare interface IBaseRequestListParams { + prev_created_at?: string, + prev_id?: string, + limit?: number, + ascending?: boolean, +} + declare interface HashMap { [key: string]: T; } diff --git a/frontend/src/types/instance.d.ts b/frontend/src/types/instance.d.ts new file mode 100644 index 000000000..58cf7c89f --- /dev/null +++ b/frontend/src/types/instance.d.ts @@ -0,0 +1,36 @@ +declare type TInstanceListRequestParams = IBaseRequestListParams & { + project_names?: string[], + fleet_ids?: string[], + only_active?: boolean, +} + +declare type TInstanceStatus = + | 'pending' + | 'creating' + | 'starting' + | 'provisioning' + | 'idle' + | 'busy' + | 'terminating' + | 'terminated'; + +declare interface IInstance { + id: string; + fleet_name: string; + fleet_id: string; + backend: TBackendType, + instance_num: number + instance_type: { + name: string, + resources: IResources + }, + name: string, + job_name: string | null, + project_name: string | null, + job_status: TJobStatus | null, + hostname: string, + status: TInstanceStatus, + created: DateTime, + region: string, + price: number | null +} diff --git a/frontend/webpack/dev.js b/frontend/webpack/dev.js index 9517cb4d8..33ff767ed 100644 --- a/frontend/webpack/dev.js +++ b/frontend/webpack/dev.js @@ -29,7 +29,14 @@ module.exports = { target: 'http://127.0.0.1:8000', logLevel: 'debug', } - ] + ], + client: { + overlay: { + runtimeErrors: (error) => { + return !error.message.includes("ResizeObserver loop completed with undelivered notifications"); + }, + }, + } }, devtool: "cheap-module-source-map", plugins: [ From f51ce52f8ab709e60dfddfecab2e6a7449af74a7 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 29 Jan 2025 09:59:23 +0300 Subject: [PATCH 2/7] [UI] New UI for fleets and instances #2133 Fixes after review --- frontend/src/libs/fleet.ts | 35 ++++++++++++++ frontend/src/locale/en.json | 3 +- frontend/src/pages/Fleets/List/hooks.tsx | 48 +++++++++---------- frontend/src/pages/Fleets/List/index.tsx | 10 ++-- .../List/hooks/useColumnDefinitions.tsx | 28 ++++++++--- frontend/src/pages/Instances/List/index.tsx | 2 +- frontend/src/types/instance.d.ts | 2 +- 7 files changed, 87 insertions(+), 41 deletions(-) diff --git a/frontend/src/libs/fleet.ts b/frontend/src/libs/fleet.ts index df331faf9..9bbef8b25 100644 --- a/frontend/src/libs/fleet.ts +++ b/frontend/src/libs/fleet.ts @@ -1,3 +1,4 @@ +import { isEqual } from 'lodash'; import { StatusIndicatorProps } from '@cloudscape-design/components'; export const getStatusIconType = (status: IInstance['status']): StatusIndicatorProps['type'] => { @@ -34,3 +35,37 @@ export const getFleetStatusIconType = (status: IFleet['status']): StatusIndicato console.error(new Error('Undefined fleet status')); } }; + +export const getFleetPrice = (fleet: IFleet): number | null => { + return fleet.instances.reduce((acc, instance) => { + if (typeof instance.price === 'number' && instance.status !== 'terminated') { + if (acc === null) return instance.price; + + acc += instance.price; + } + + return acc; + }, null); +}; + +const getInstanceFields = (instance: IInstance) => ({ + backend: instance.backend, + region: instance.region, + type: instance.instance_type?.name, + spot: instance.instance_type?.resources.spot, +}); + +export const getFleetInstancesLinkText = (fleet: IFleet): string | null => { + const instances = fleet.instances.filter((i) => i.status !== 'terminated'); + + if (!instances.length) return null; + + const isSameInstances = instances.every((i) => isEqual(getInstanceFields(instances[0]), getInstanceFields(i))); + + if (isSameInstances) + return `${instances.length}x ${instances[0].instance_type?.name}${ + instances[0].instance_type?.resources.spot ? ' (spot)' : '' + } @ ${instances[0].backend} (${instances[0].region})`; + + return `${instances.length} instances`; +}; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index a0531f84b..8b6853269 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -432,7 +432,7 @@ "nomatch_message_title": "No matches", "nomatch_message_text": "We can't find a match.", "nomatch_message_button_label": "Clear filter", - "active_only": "Active instances", + "active_only": "Active fleets", "statuses": { "active": "Active", "submitted": "Submitted", @@ -441,6 +441,7 @@ "terminated": "Terminated" }, "instances": { + "active_only": "Active instances", "title": "Instances", "empty_message_title": "No instances", "empty_message_text": "No instances to display.", diff --git a/frontend/src/pages/Fleets/List/hooks.tsx b/frontend/src/pages/Fleets/List/hooks.tsx index 01e101337..6388fa2c4 100644 --- a/frontend/src/pages/Fleets/List/hooks.tsx +++ b/frontend/src/pages/Fleets/List/hooks.tsx @@ -2,12 +2,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next'; import { format } from 'date-fns'; -import { Button, Icon, ListEmptyMessage, NavigateLink, StatusIndicator, TableProps } from 'components'; +import { Button, ListEmptyMessage, NavigateLink, StatusIndicator, TableProps } from 'components'; import { SelectCSDProps } from 'components'; import { DATE_TIME_FORMAT, DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useLocalStorageState } from 'hooks/useLocalStorageState'; -import { getFleetStatusIconType } from 'libs/fleet'; +import { getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; import { ROUTES } from 'routes'; import { useLazyGetFleetsQuery } from 'services/fleet'; import { useGetProjectsQuery } from 'services/project'; @@ -71,25 +71,17 @@ export const useColumnsDefinitions = () => { {item.project_name} ), }, - // { - // id: 'resources', - // header: t('fleets.instances.resources'), - // cell: (item) => item.instance_type?.resources.description, - // }, { - id: 'backend', - header: t('fleets.instances.backend'), - cell: (item) => item.spec.configuration?.backends?.join(', '), - }, - { - id: 'region', - header: t('fleets.instances.region'), - cell: (item) => item.spec.configuration.regions?.join(', '), - }, - { - id: 'spot', - header: t('fleets.instances.spot'), - cell: (item) => item.spec.configuration?.spot_policy === 'spot' && , + id: 'instances', + header: t('fleets.instances.title'), + cell: (item) => { + const linkText = getFleetInstancesLinkText(item); + + if (linkText) + return {linkText}; + + return '-'; + }, }, { id: 'started', @@ -99,7 +91,13 @@ export const useColumnsDefinitions = () => { { id: 'price', header: t('fleets.instances.price'), - cell: (item) => item.spec.configuration?.max_price && `$${item.spec.configuration?.max_price}`, + cell: (item) => { + const price = getFleetPrice(item); + + if (typeof price === 'number') return `$${price}`; + + return '-'; + }, }, ]; @@ -107,8 +105,7 @@ export const useColumnsDefinitions = () => { }; export const useFilters = () => { - // const [onlyActive, setOnlyActive] = useLocalStorageState('fleet-list-is-active', false); - const [onlyActive, setOnlyActive] = useState(true); + const [onlyActive, setOnlyActive] = useLocalStorageState('fleet-list-is-active', true); const [selectedProject, setSelectedProject] = useState(null); const { data: projectsData } = useGetProjectsQuery(); @@ -120,12 +117,11 @@ export const useFilters = () => { }, [projectsData]); const clearFilters = () => { - // setOnlyActive(false); + setOnlyActive(false); setSelectedProject(null); }; - // const isDisabledClearFilter = !selectedProject && !onlyActive; - const isDisabledClearFilter = !selectedProject; + const isDisabledClearFilter = !selectedProject && !onlyActive; return { projectOptions, diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index ec359a211..8aa4bb1c0 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -110,11 +110,11 @@ export const FleetList: React.FC = () => { - {/*
*/} - {/* setOnlyActive(detail.checked)} checked={onlyActive}>*/} - {/* {t('fleets.active_only')}*/} - {/* */} - {/*
*/} +
+ setOnlyActive(detail.checked)} checked={onlyActive}> + {t('fleets.active_only')} + +
+ + } + /> + } + > {isLoading && ( @@ -89,18 +128,13 @@ export const FleetDetails: React.FC = () => {
- {t('fleets.instances.backend')} -
{data.spec.configuration?.backends?.join(', ')}
-
- -
- {t('fleets.instances.region')} -
{data.spec.configuration.regions?.join(', ')}
-
+ {t('fleets.instances.title')} -
- {t('fleets.instances.region')} -
{data.spec.configuration?.spot_policy === 'spot' && }
+
+ + {getFleetInstancesLinkText(data)} + +
@@ -110,17 +144,7 @@ export const FleetDetails: React.FC = () => {
{t('fleets.instances.price')} -
{data.spec.configuration?.max_price && `$${data.spec.configuration?.max_price}`}
-
- -
- {t('fleets.instances.title')} - -
- - Show fleet's instances - -
+
{renderPrice(data)}
diff --git a/frontend/src/pages/Fleets/List/hooks.tsx b/frontend/src/pages/Fleets/List/hooks.tsx index 6388fa2c4..9631fc4ae 100644 --- a/frontend/src/pages/Fleets/List/hooks.tsx +++ b/frontend/src/pages/Fleets/List/hooks.tsx @@ -52,7 +52,7 @@ export const useColumnsDefinitions = () => { id: 'fleet_name', header: t('fleets.fleet'), cell: (item) => ( - {item.name} + {item.name} ), }, { @@ -74,14 +74,11 @@ export const useColumnsDefinitions = () => { { id: 'instances', header: t('fleets.instances.title'), - cell: (item) => { - const linkText = getFleetInstancesLinkText(item); - - if (linkText) - return {linkText}; - - return '-'; - }, + cell: (item) => ( + + {getFleetInstancesLinkText(item)} + + ), }, { id: 'started', @@ -205,7 +202,8 @@ export const useFleetsData = ({ project_name, only_active }: TFleetListRequestPa if (result.length > 0) { setPagesCount((count) => count - 1); - setData(result); + const reversedData = [...result].reverse(); + setData(reversedData); } else { setPagesCount(1); } diff --git a/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx index 5a2124272..941f87c65 100644 --- a/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx @@ -18,7 +18,7 @@ export const useColumnsDefinitions = () => { header: t('fleets.fleet'), cell: (item) => item.fleet_name && item.project_name ? ( - + {item.fleet_name} ) : ( diff --git a/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts b/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts index fabdb484b..fe834bc6f 100644 --- a/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts +++ b/frontend/src/pages/Instances/List/hooks/useInstanceListData.ts @@ -76,7 +76,8 @@ export const useInstanceListData = ({ project_names, only_active, fleet_ids }: T if (result.length > 0) { setPagesCount((count) => count - 1); - setData(result); + const reversedData = [...result].reverse(); + setData(reversedData); } else { setPagesCount(1); } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 07065aee4..b403b5588 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -80,9 +80,9 @@ export const ROUTES = { FLEETS: { LIST: '/fleets', DETAILS: { - TEMPLATE: `/projects/:projectName/fleets/:fleetName`, - FORMAT: (projectName: string, fleetName: string) => - buildRoute(ROUTES.FLEETS.DETAILS.TEMPLATE, { projectName, fleetName }), + TEMPLATE: `/projects/:projectName/fleets/:fleetId`, + FORMAT: (projectName: string, fleetId: string) => + buildRoute(ROUTES.FLEETS.DETAILS.TEMPLATE, { projectName, fleetId }), }, }, diff --git a/frontend/src/services/fleet.ts b/frontend/src/services/fleet.ts index d0b37b695..e74753b3c 100644 --- a/frontend/src/services/fleet.ts +++ b/frontend/src/services/fleet.ts @@ -37,13 +37,17 @@ export const fleetApi = createApi({ result ? [...result.map(({ name }) => ({ type: 'Fleet' as const, id: name })), 'Fleets'] : ['Fleets'], }), - getFleetDetails: builder.query({ - query: ({ projectName, fleetName }) => { + getFleetDetails: builder.query< + IFleet, + { projectName: IProject['project_name']; fleetName?: IFleet['name']; fleetId?: IFleet['id'] } + >({ + query: ({ projectName, fleetName, fleetId }) => { return { url: API.PROJECTS.FLEETS_DETAILS(projectName), method: 'POST', body: { name: fleetName, + id: fleetId, }, }; }, From 6d208a24a50d83ded8e3059ecafa37700add214b Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 29 Jan 2025 17:27:52 +0300 Subject: [PATCH 4/7] [UI] New UI for fleets and instances #2133 Fixes after review --- frontend/src/libs/fleet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/libs/fleet.ts b/frontend/src/libs/fleet.ts index 1d5310e33..6b4c0693e 100644 --- a/frontend/src/libs/fleet.ts +++ b/frontend/src/libs/fleet.ts @@ -57,7 +57,7 @@ const getInstanceFields = (instance: IInstance) => ({ export const getFleetInstancesLinkText = (fleet: IFleet): string => { const instances = fleet.instances.filter((i) => i.status !== 'terminated'); - const hasPending = instances.some((i) => i.status !== 'pending'); + const hasPending = instances.some((i) => i.status === 'pending'); if (!instances.length) return '0 instances'; From 2d66a014297624e146aae31402d5ab09c1f73a65 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 29 Jan 2025 17:42:06 +0300 Subject: [PATCH 5/7] [UI] New UI for fleets and instances #2133 Fixes after review --- frontend/src/pages/Fleets/Details/index.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Fleets/Details/index.tsx b/frontend/src/pages/Fleets/Details/index.tsx index 13d2f3687..eb6139386 100644 --- a/frontend/src/pages/Fleets/Details/index.tsx +++ b/frontend/src/pages/Fleets/Details/index.tsx @@ -34,10 +34,15 @@ export const FleetDetails: React.FC = () => { const { deleteFleets, isDeleting } = useDeleteFleet(); - const { data, isLoading } = useGetFleetDetailsQuery({ - projectName: paramProjectName, - fleetId: paramFleetId, - }); + const { data, isLoading } = useGetFleetDetailsQuery( + { + projectName: paramProjectName, + fleetId: paramFleetId, + }, + { + refetchOnMountOrArgChange: true, + }, + ); useBreadcrumbs([ { From 3bf0b7ebab447a6722233df1289a4ac5b689c769 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 29 Jan 2025 17:43:55 +0300 Subject: [PATCH 6/7] [UI] New UI for fleets and instances #2133 Fixes after review --- frontend/src/pages/Fleets/Details/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/Fleets/Details/index.tsx b/frontend/src/pages/Fleets/Details/index.tsx index eb6139386..658d61c01 100644 --- a/frontend/src/pages/Fleets/Details/index.tsx +++ b/frontend/src/pages/Fleets/Details/index.tsx @@ -11,7 +11,6 @@ import { ContentLayout, DetailsHeader, Header, - Icon, Loader, NavigateLink, StatusIndicator, From 64c62f4dbef994c6f10d277f09085852ab21e825 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Thu, 30 Jan 2025 09:49:45 +0300 Subject: [PATCH 7/7] [UI] New UI for fleets and instances #2133 Fixes after review --- frontend/src/pages/Fleets/List/useDeleteFleet.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Fleets/List/useDeleteFleet.ts b/frontend/src/pages/Fleets/List/useDeleteFleet.ts index 79742bfd0..ab0af440a 100644 --- a/frontend/src/pages/Fleets/List/useDeleteFleet.ts +++ b/frontend/src/pages/Fleets/List/useDeleteFleet.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { get } from 'lodash'; import { useNotifications } from 'hooks'; import { useDeleteFleetMutation } from 'services/fleet'; @@ -41,7 +42,7 @@ export const useDeleteFleet = () => { .catch((error) => { pushNotification({ type: 'error', - content: t('common.server_error', { error: error?.error }), + content: t('common.server_error', { error: get(error, `data.[0].detail.msg`) ?? error?.error }), }); }); }, []);