diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f7c9e20d51..6615381260 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -171,6 +171,8 @@ export const API = { INSTANCES: { BASE: () => `${API.BASE()}/instances`, LIST: () => `${API.INSTANCES.BASE()}/list`, + DETAILS: (projectName: IProject['project_name']) => + `${API.BASE()}/project/${projectName}/instances/get`, }, SERVER: { diff --git a/frontend/src/libs/fleet.ts b/frontend/src/libs/fleet.ts index 6b4c0693ec..c3a7912353 100644 --- a/frontend/src/libs/fleet.ts +++ b/frontend/src/libs/fleet.ts @@ -1,6 +1,12 @@ import { isEqual } from 'lodash'; import { StatusIndicatorProps } from '@cloudscape-design/components'; +export const formatBackend = (backend: TBackendType | string | null | undefined): string => { + if (!backend) return '-'; + if (backend === 'remote') return 'ssh'; + return backend; +}; + export const getStatusIconType = (status: IInstance['status']): StatusIndicatorProps['type'] => { switch (status) { case 'pending': @@ -55,6 +61,90 @@ const getInstanceFields = (instance: IInstance) => ({ spot: instance.instance_type?.resources.spot, }); +const formatRange = (min: unknown, max: unknown, suffix = ''): string => { + if (min == null && max == null) return ''; + if (min === max) return `${min}${suffix}`; + if (max == null) return `${min}${suffix}..`; + if (min == null) return `..${max}${suffix}`; + return `${min}${suffix}..${max}${suffix}`; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const formatCpu = (cpu: any): string | null => { + if (!cpu) return null; + if (typeof cpu === 'number') return `cpu=${cpu}`; + if (cpu.min != null || cpu.max != null) return `cpu=${formatRange(cpu.min, cpu.max)}`; + const arch = cpu.arch; + const count = cpu.count; + if (!count) return null; + const prefix = arch === 'arm' ? 'arm:' : ''; + return `cpu=${prefix}${formatRange(count.min, count.max)}`; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const formatGpu = (gpu: any): string | null => { + if (!gpu) return null; + const count = gpu.count; + if (!count || (count.min === 0 && (count.max == null || count.max === 0))) return null; + + const gpuParts: string[] = []; + + if (gpu.memory) { + const memStr = formatRange(gpu.memory.min, gpu.memory.max, 'GB'); + if (memStr) gpuParts.push(memStr); + } + + const countStr = formatRange(count.min, count.max); + if (countStr) gpuParts.push(countStr); + + if (gpu.total_memory) { + const tmStr = formatRange(gpu.total_memory.min, gpu.total_memory.max, 'GB'); + if (tmStr) gpuParts.push(tmStr); + } + + let label: string; + if (gpu.name && gpu.name.length > 0) { + label = gpu.name.join(','); + } else if (gpu.vendor) { + label = gpu.vendor; + } else { + label = ''; + } + + return 'gpu=' + [label, ...gpuParts].filter(Boolean).join(':'); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const formatFleetResources = (resources: any): string => { + if (!resources) return '-'; + + const parts: string[] = []; + + const cpuStr = formatCpu(resources.cpu); + if (cpuStr) parts.push(cpuStr); + + if (resources.memory) { + const memStr = formatRange(resources.memory.min, resources.memory.max, 'GB'); + if (memStr) parts.push(`mem=${memStr}`); + } + + if (resources.disk?.size) { + const diskStr = formatRange(resources.disk.size.min, resources.disk.size.max, 'GB'); + if (diskStr) parts.push(`disk=${diskStr}`); + } + + const gpuStr = formatGpu(resources.gpu); + if (gpuStr) parts.push(gpuStr); + + return parts.length > 0 ? parts.join(' ') : '-'; +}; + +export const formatFleetBackend = (config: IFleetConfigurationRequest): string => { + if (config.ssh_config) return 'ssh'; + if (!config.backends || config.backends.length === 0) return '-'; + return config.backends.map((b) => formatBackend(b)).join(', '); +}; + export const getFleetInstancesLinkText = (fleet: IFleet): string => { const instances = fleet.instances.filter((i) => i.status !== 'terminated'); const hasPending = instances.some((i) => i.status === 'pending'); @@ -68,7 +158,7 @@ export const getFleetInstancesLinkText = (fleet: IFleet): string => { if (isSameInstances) return `${instances.length}x ${instances[0].instance_type?.name}${ instances[0].instance_type?.resources.spot ? ' (spot)' : '' - } @ ${instances[0].backend} (${instances[0].region})`; + } @ ${formatBackend(instances[0].backend)} (${instances[0].region})`; return `${instances.length} instances`; }; diff --git a/frontend/src/libs/instance.ts b/frontend/src/libs/instance.ts new file mode 100644 index 0000000000..fae5c60b71 --- /dev/null +++ b/frontend/src/libs/instance.ts @@ -0,0 +1,32 @@ +import { StatusIndicatorProps } from '@cloudscape-design/components'; + +export const prettyEnumValue = (value: string): string => { + return value.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); +}; + +export const getHealthStatusIconType = (healthStatus: THealthStatus): StatusIndicatorProps['type'] => { + switch (healthStatus) { + case 'healthy': + return 'success'; + case 'warning': + return 'warning'; + case 'failure': + return 'error'; + default: + return 'info'; + } +}; + +export const formatInstanceStatusText = (instance: IInstance): string => { + const status = instance.status; + + if ( + (status === 'idle' || status === 'busy') && + instance.total_blocks !== null && + instance.total_blocks > 1 + ) { + return `${instance.busy_blocks}/${instance.total_blocks} Busy`; + } + + return prettyEnumValue(status); +}; diff --git a/frontend/src/libs/resources.ts b/frontend/src/libs/resources.ts new file mode 100644 index 0000000000..17ec638740 --- /dev/null +++ b/frontend/src/libs/resources.ts @@ -0,0 +1,39 @@ +const mibToGB = (mib: number): string => `${Math.round(mib / 1024)}GB`; + +export const formatResources = (resources: IResources, includeSpot = true): string => { + const parts: string[] = []; + + if (resources.cpus > 0) { + const archPrefix = resources.cpu_arch === 'arm' ? 'arm:' : ''; + parts.push(`cpu=${archPrefix}${resources.cpus}`); + } + + if (resources.memory_mib > 0) { + parts.push(`mem=${mibToGB(resources.memory_mib)}`); + } + + if (resources.disk && resources.disk.size_mib > 0) { + parts.push(`disk=${mibToGB(resources.disk.size_mib)}`); + } + + if (resources.gpus.length > 0) { + const gpu = resources.gpus[0]; + const gpuParts: string[] = []; + + if (gpu.memory_mib > 0) { + gpuParts.push(mibToGB(gpu.memory_mib)); + } + + gpuParts.push(String(resources.gpus.length)); + + parts.push('gpu=' + [gpu.name, ...gpuParts].filter(Boolean).join(':')); + } + + let output = parts.join(' '); + + if (includeSpot && resources.spot) { + output += ' (spot)'; + } + + return output || '-'; +}; diff --git a/frontend/src/libs/run.ts b/frontend/src/libs/run.ts index 1c528b8b5f..60f4a51083 100644 --- a/frontend/src/libs/run.ts +++ b/frontend/src/libs/run.ts @@ -2,6 +2,7 @@ import { get as _get } from 'lodash'; import { StatusIndicatorProps } from '@cloudscape-design/components'; import { capitalize } from 'libs'; +import { formatResources } from 'libs/resources'; import { finishedRunStatuses } from '../pages/Runs/constants'; import { getJobProbesStatuses } from '../pages/Runs/Details/Jobs/List/helpers'; @@ -99,7 +100,9 @@ export const getExtendedModelFromRun = (run: IRun): IModelExtended | null => { project_name: run.project_name, run_name: run?.run_spec.run_name ?? 'No run name', user: run.user, - resources: run.latest_job_submission?.job_provisioning_data?.instance_type?.resources?.description ?? null, + resources: run.latest_job_submission?.job_provisioning_data?.instance_type?.resources + ? formatResources(run.latest_job_submission.job_provisioning_data.instance_type.resources) + : null, price: run.latest_job_submission?.job_provisioning_data?.price ?? null, submitted_at: run.submitted_at, repository: getRepoNameFromRun(run), diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 0b6bbc08c9..3134782e76 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -584,6 +584,7 @@ "button_title": "Create a fleet" }, "fleet": "Fleet", + "fleet_column_name": "Name", "fleet_placeholder": "Filtering by fleet", "fleet_name": "Fleet name", "total_instances": "Number of instances", @@ -613,12 +614,12 @@ "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", + "instance_name": "Name", + "instance_num": "Num", "created": "Created", "status": "Status", "project": "Project", - "hostname": "Host name", + "hostname": "Hostname", "instance_type": "Type", "statuses": { "pending": "Pending", @@ -633,7 +634,12 @@ "region": "Region", "spot": "Spot", "started": "Started", - "price": "Price" + "finished_at": "Finished", + "price": "Price", + "termination_reason": "Termination reason", + "health": "Health", + "blocks": "Blocks", + "inspect": "Inspect" }, "edit": { "name": "Name", diff --git a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx index bc9a07aa0c..fce75101ff 100644 --- a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx @@ -77,7 +77,12 @@ export const useColumnsDefinitions = () => { {target.project_name} )} - /{target.name} + / + + {target.name} + ); diff --git a/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx b/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx index 19d818c236..5612662817 100644 --- a/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx +++ b/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx @@ -6,7 +6,7 @@ import { format } from 'date-fns'; import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, StatusIndicator } from 'components'; import { DATE_TIME_FORMAT } from 'consts'; -import { getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; +import { formatFleetBackend, formatFleetResources, getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; import { ROUTES } from 'routes'; import { useGetFleetDetailsQuery } from 'services/fleet'; @@ -26,14 +26,6 @@ export const FleetDetails = () => { }, ); - const renderPrice = (fleet: IFleet) => { - const price = getFleetPrice(fleet); - - if (typeof price === 'number') return `$${price}`; - - return '-'; - }; - return ( <> {isLoading && ( @@ -70,6 +62,16 @@ export const FleetDetails = () => { +
+ {t('fleets.instances.backend')} +
{formatFleetBackend(data.spec.configuration)}
+
+ +
+ {t('fleets.instances.resources')} +
{formatFleetResources(data.spec.configuration.resources)}
+
+
{t('fleets.instances.title')} @@ -81,13 +83,13 @@ export const FleetDetails = () => {
- {t('fleets.instances.started')} + {t('fleets.instances.created')}
{format(new Date(data.created_at), DATE_TIME_FORMAT)}
{t('fleets.instances.price')} -
{renderPrice(data)}
+
{(() => { const p = getFleetPrice(data); return typeof p === 'number' ? `$${p}` : '-'; })()}
diff --git a/frontend/src/pages/Fleets/List/hooks.tsx b/frontend/src/pages/Fleets/List/hooks.tsx index b9789fd41b..639d7b8683 100644 --- a/frontend/src/pages/Fleets/List/hooks.tsx +++ b/frontend/src/pages/Fleets/List/hooks.tsx @@ -10,7 +10,7 @@ import { Button, ListEmptyMessage, NavigateLink, StatusIndicator, TableProps } f import { DATE_TIME_FORMAT } from 'consts'; import { useProjectFilter } from 'hooks/useProjectFilter'; import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams, tokensToSearchParams } from 'libs/filters'; -import { getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; +import { formatFleetBackend, formatFleetResources, getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; import { ROUTES } from 'routes'; export const useEmptyMessages = ({ @@ -51,7 +51,7 @@ export const useColumnsDefinitions = () => { const columns: TableProps.ColumnDefinition[] = [ { id: 'fleet_name', - header: t('fleets.fleet'), + header: t('fleets.fleet_column_name'), cell: (item) => ( {item.name} ), @@ -72,6 +72,16 @@ export const useColumnsDefinitions = () => { {item.project_name} ), }, + { + id: 'backend', + header: t('fleets.instances.backend'), + cell: (item) => formatFleetBackend(item.spec.configuration), + }, + { + id: 'resources', + header: t('fleets.instances.resources'), + cell: (item) => formatFleetResources(item.spec.configuration.resources), + }, { id: 'instances', header: t('fleets.instances.title'), @@ -82,8 +92,8 @@ export const useColumnsDefinitions = () => { ), }, { - id: 'started', - header: t('fleets.instances.started'), + id: 'created', + header: t('fleets.instances.created'), cell: (item) => format(new Date(item.created_at), DATE_TIME_FORMAT), }, { @@ -91,10 +101,7 @@ export const useColumnsDefinitions = () => { header: t('fleets.instances.price'), cell: (item) => { const price = getFleetPrice(item); - - if (typeof price === 'number') return `$${price}`; - - return '-'; + return typeof price === 'number' ? `$${price}` : '-'; }, }, ]; diff --git a/frontend/src/pages/Instances/Details/Events/index.tsx b/frontend/src/pages/Instances/Details/Events/index.tsx new file mode 100644 index 0000000000..53a07f1cdc --- /dev/null +++ b/frontend/src/pages/Instances/Details/Events/index.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import Button from '@cloudscape-design/components/button'; + +import { Header, Loader, Table } from 'components'; + +import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useCollection, useInfiniteScroll } from 'hooks'; +import { ROUTES } from 'routes'; +import { useLazyGetAllEventsQuery } from 'services/events'; + +import { useColumnsDefinitions } from 'pages/Events/List/hooks/useColumnDefinitions'; + +export const EventsList = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramInstanceId = params.instanceId ?? ''; + const navigate = useNavigate(); + + const { data, isLoading, isLoadingMore } = useInfiniteScroll({ + useLazyQuery: useLazyGetAllEventsQuery, + args: { limit: DEFAULT_TABLE_PAGE_SIZE, target_instances: [paramInstanceId] }, + + getPaginationParams: (lastEvent) => ({ + prev_recorded_at: lastEvent.recorded_at, + prev_id: lastEvent.id, + }), + }); + + const { items, collectionProps } = useCollection(data, { + selection: {}, + }); + + const goToFullView = () => { + navigate(ROUTES.EVENTS.LIST + `?target_instances=${paramInstanceId}`); + }; + + const { columns } = useColumnsDefinitions(); + + return ( + {t('common.full_view')}}> + {t('navigation.events')} + + } + footer={} + /> + ); +}; diff --git a/frontend/src/pages/Instances/Details/Inspect/index.tsx b/frontend/src/pages/Instances/Details/Inspect/index.tsx new file mode 100644 index 0000000000..a9c9ac2594 --- /dev/null +++ b/frontend/src/pages/Instances/Details/Inspect/index.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { CodeEditor, Container, Header, Loader } from 'components'; + +import { useGetInstanceDetailsQuery } from 'services/instance'; + +interface AceEditorElement extends HTMLElement { + env?: { + editor?: { + setReadOnly: (readOnly: boolean) => void; + }; + }; +} + +export const InstanceInspect = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramInstanceId = params.instanceId ?? ''; + + const { data, isLoading } = useGetInstanceDetailsQuery( + { + projectName: paramProjectName, + instanceId: paramInstanceId, + }, + { + refetchOnMountOrArgChange: true, + }, + ); + + const jsonContent = useMemo(() => { + if (!data) return ''; + return JSON.stringify(data, null, 2); + }, [data]); + + useEffect(() => { + const timer = setTimeout(() => { + const editorElements = document.querySelectorAll('.ace_editor'); + editorElements.forEach((element: Element) => { + const aceEditor = (element as AceEditorElement).env?.editor; + if (aceEditor) { + aceEditor.setReadOnly(true); + } + }); + }, 100); + + return () => clearTimeout(timer); + }, [jsonContent]); + + if (isLoading) + return ( + + + + ); + + return ( + {t('fleets.instances.inspect')}}> + {}} + /> + + ); +}; diff --git a/frontend/src/pages/Instances/Details/InstanceDetails/index.tsx b/frontend/src/pages/Instances/Details/InstanceDetails/index.tsx new file mode 100644 index 0000000000..408698b777 --- /dev/null +++ b/frontend/src/pages/Instances/Details/InstanceDetails/index.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { format } from 'date-fns'; + +import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, StatusIndicator } from 'components'; + +import { DATE_TIME_FORMAT } from 'consts'; +import { formatBackend, getStatusIconType } from 'libs/fleet'; +import { getHealthStatusIconType, prettyEnumValue } from 'libs/instance'; +import { formatResources } from 'libs/resources'; +import { ROUTES } from 'routes'; +import { useGetInstanceDetailsQuery } from 'services/instance'; + +export const InstanceDetails = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramInstanceId = params.instanceId ?? ''; + const paramProjectName = params.projectName ?? ''; + + const { data, isLoading } = useGetInstanceDetailsQuery( + { + projectName: paramProjectName, + instanceId: paramInstanceId, + }, + { + refetchOnMountOrArgChange: true, + }, + ); + + return ( + <> + {isLoading && ( + + + + )} + + {data && ( + {t('common.general')}}> + +
+ {t('fleets.instances.project')} +
+ + {data.project_name} + +
+
+ +
+ {t('fleets.fleet')} +
+ {data.fleet_name && data.fleet_id ? ( + + {data.fleet_name} + + ) : ( + '-' + )} +
+
+ +
+ {t('fleets.instances.status')} +
+ + {(data.status === 'idle' || data.status === 'busy') && + data.total_blocks !== null && + data.total_blocks > 1 + ? `${data.busy_blocks}/${data.total_blocks} Busy` + : prettyEnumValue(data.status)} + +
+
+ +
+ {t('projects.run.error')} +
+ {data.unreachable ? ( + Unreachable + ) : data.health_status !== 'healthy' ? ( + + {prettyEnumValue(data.health_status)} + + ) : ( + '-' + )} +
+
+ +
+ {t('fleets.instances.started')} +
{format(new Date(data.created), DATE_TIME_FORMAT)}
+
+ +
+ {t('fleets.instances.finished_at')} +
+ {data.finished_at ? format(new Date(data.finished_at), DATE_TIME_FORMAT) : '-'} +
+
+ + {data.termination_reason && ( +
+ {t('fleets.instances.termination_reason')} +
+ {data.termination_reason_message ?? prettyEnumValue(data.termination_reason)} +
+
+ )} + +
+ {t('fleets.instances.resources')} +
{data.instance_type ? formatResources(data.instance_type.resources) : '-'}
+
+ +
+ {t('fleets.instances.backend')} +
{formatBackend(data.backend)}
+
+ +
+ {t('fleets.instances.region')} +
{data.region ?? '-'}
+
+ +
+ {t('fleets.instances.instance_type')} +
{data.instance_type?.name ?? '-'}
+
+ +
+ {t('fleets.instances.spot')} +
{data.instance_type?.resources.spot ? t('common.yes') : t('common.no')}
+
+ +
+ {t('fleets.instances.price')} +
{typeof data.price === 'number' ? `$${data.price}` : '-'}
+
+ + {data.total_blocks !== null && ( +
+ {t('fleets.instances.blocks')} +
{data.total_blocks}
+
+ )} + +
+ {t('fleets.instances.hostname')} +
{data.hostname ?? '-'}
+
+
+
+ )} + + ); +}; diff --git a/frontend/src/pages/Instances/Details/index.tsx b/frontend/src/pages/Instances/Details/index.tsx new file mode 100644 index 0000000000..2c7f1a2b5c --- /dev/null +++ b/frontend/src/pages/Instances/Details/index.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; + +import { Button, ContentLayout, DetailsHeader, Tabs } from 'components'; + +enum InstanceTab { + Details = 'details', + Events = 'events', + Inspect = 'inspect', +} + +import { useBreadcrumbs } from 'hooks'; +import { ROUTES } from 'routes'; +import { useGetInstanceDetailsQuery } from 'services/instance'; + +import { useDeleteInstance } from './useDeleteInstance'; + +import styles from './styles.module.scss'; + +export const InstanceDetailsPage: React.FC = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramInstanceId = params.instanceId ?? ''; + const paramProjectName = params.projectName ?? ''; + const navigate = useNavigate(); + + const { deleteInstance, isDeleting } = useDeleteInstance(); + + const { data } = useGetInstanceDetailsQuery( + { + projectName: paramProjectName, + instanceId: paramInstanceId, + }, + { + refetchOnMountOrArgChange: true, + }, + ); + + useBreadcrumbs([ + { + text: t('navigation.project_other'), + href: ROUTES.PROJECT.LIST, + }, + { + text: paramProjectName, + href: ROUTES.PROJECT.DETAILS.FORMAT(paramProjectName), + }, + { + text: t('navigation.instances'), + href: ROUTES.INSTANCES.LIST, + }, + { + text: data?.name ?? '', + href: ROUTES.INSTANCES.DETAILS.FORMAT(paramProjectName, paramInstanceId), + }, + ]); + + const deleteClickHandle = () => { + if (!data) return; + + deleteInstance(data) + .then(() => { + navigate(ROUTES.INSTANCES.LIST); + }) + .catch(console.log); + }; + + const isDisabledDeleteButton = !data || isDeleting || data.status === 'terminated'; + + return ( +
+ + {t('common.delete')} + + } + /> + } + > + + + + +
+ ); +}; diff --git a/frontend/src/pages/Instances/Details/styles.module.scss b/frontend/src/pages/Instances/Details/styles.module.scss new file mode 100644 index 0000000000..1a7d41a9c5 --- /dev/null +++ b/frontend/src/pages/Instances/Details/styles.module.scss @@ -0,0 +1,18 @@ +.page { + height: 100%; + + & [class^="awsui_tabs-content"] { + display: none; + } + + & > [class^="awsui_layout"] { + height: 100%; + + & > [class^="awsui_content"] { + display: flex; + flex-direction: column; + gap: 20px; + height: 100%; + } + } +} diff --git a/frontend/src/pages/Instances/Details/useDeleteInstance.ts b/frontend/src/pages/Instances/Details/useDeleteInstance.ts new file mode 100644 index 0000000000..b460ed4373 --- /dev/null +++ b/frontend/src/pages/Instances/Details/useDeleteInstance.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useNotifications } from 'hooks'; +import { getServerError } from 'libs'; +import { useDeleteInstancesMutation } from 'services/instance'; + +export const useDeleteInstance = () => { + const { t } = useTranslation(); + const [deleteInstances] = useDeleteInstancesMutation(); + const [pushNotification] = useNotifications(); + const [isDeleting, setIsDeleting] = useState(false); + + const deleteInstance = useCallback(async (instance: IInstance) => { + if (!instance.project_name || !instance.fleet_name) { + return Promise.reject('Missing project or fleet name'); + } + + setIsDeleting(true); + + return deleteInstances({ + projectName: instance.project_name, + fleetName: instance.fleet_name, + instancesNums: [instance.instance_num], + }) + .unwrap() + .finally(() => setIsDeleting(false)) + .catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + throw error; + }); + }, []); + + return { deleteInstance, isDeleting } as const; +}; diff --git a/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx index 941f87c65e..c00834fb23 100644 --- a/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Instances/List/hooks/useColumnDefinitions.tsx @@ -5,14 +5,25 @@ import { format } from 'date-fns'; import { Icon, NavigateLink, StatusIndicator, TableProps } from 'components'; import { DATE_TIME_FORMAT } from 'consts'; -import { getStatusIconType } from 'libs/fleet'; - -import { ROUTES } from '../../../../routes'; +import { formatBackend, getStatusIconType } from 'libs/fleet'; +import { formatInstanceStatusText, getHealthStatusIconType, prettyEnumValue } from 'libs/instance'; +import { formatResources } from 'libs/resources'; +import { ROUTES } from 'routes'; export const useColumnsDefinitions = () => { const { t } = useTranslation(); const columns: TableProps.ColumnDefinition[] = [ + { + id: 'name', + header: t('fleets.instances.instance_name'), + cell: (item) => + item.project_name ? ( + {item.name} + ) : ( + item.name + ), + }, { id: 'fleet_name', header: t('fleets.fleet'), @@ -25,11 +36,6 @@ export const useColumnsDefinitions = () => { '-' ), }, - { - id: 'instance_num', - header: t('fleets.instances.instance_num'), - cell: (item) => item.instance_num, - }, { id: 'project_name', header: t('fleets.instances.project'), @@ -40,6 +46,27 @@ export const useColumnsDefinitions = () => { item.project_name ), }, + { + id: 'status', + header: t('fleets.instances.status'), + cell: (item) => ( + {formatInstanceStatusText(item)} + ), + }, + { + id: 'error', + header: t('projects.run.error'), + cell: (item) => { + if (item.unreachable) return Unreachable; + if (item.health_status !== 'healthy') + return ( + + {prettyEnumValue(item.health_status)} + + ); + return null; + }, + }, { id: 'hostname', header: t('fleets.instances.hostname'), @@ -48,7 +75,12 @@ export const useColumnsDefinitions = () => { { id: 'backend', header: t('fleets.instances.backend'), - cell: (item) => item.backend, + cell: (item) => formatBackend(item.backend), + }, + { + id: 'price', + header: t('fleets.instances.price'), + cell: (item) => (typeof item.price === 'number' ? `$${item.price}` : '-'), }, { id: 'region', @@ -63,31 +95,22 @@ export const useColumnsDefinitions = () => { { id: 'resources', header: t('fleets.instances.resources'), - cell: (item) => item.instance_type?.resources.description ?? '-', + cell: (item) => (item.instance_type ? formatResources(item.instance_type.resources) : '-'), }, { id: 'spot', header: t('fleets.instances.spot'), cell: (item) => item.instance_type?.resources.spot && , }, - { - id: 'status', - header: t('fleets.instances.status'), - cell: (item) => ( - - {t(`fleets.instances.statuses.${item.status}`)} - - ), - }, { id: 'started', header: t('fleets.instances.started'), cell: (item) => format(new Date(item.created), DATE_TIME_FORMAT), }, { - id: 'price', - header: t('fleets.instances.price'), - cell: (item) => (typeof item.price === 'number' ? `$${item.price}` : '-'), + id: 'finished_at', + header: t('fleets.instances.finished_at'), + cell: (item) => (item.finished_at ? format(new Date(item.finished_at), DATE_TIME_FORMAT) : '-'), }, ]; diff --git a/frontend/src/pages/Instances/index.ts b/frontend/src/pages/Instances/index.ts index ee1bcdcc2d..15199c378d 100644 --- a/frontend/src/pages/Instances/index.ts +++ b/frontend/src/pages/Instances/index.ts @@ -1 +1,2 @@ export { List as InstanceList } from './List'; +export { InstanceDetailsPage } from './Details'; diff --git a/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts b/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts index cb5ef5a468..6fd1f30778 100644 --- a/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts +++ b/frontend/src/pages/Runs/Details/Jobs/List/helpers.ts @@ -3,9 +3,12 @@ import type { StatusIndicatorProps } from '@cloudscape-design/components/status- import { DATE_TIME_FORMAT } from 'consts'; import { capitalize } from 'libs'; +import { formatBackend } from 'libs/fleet'; +import { formatResources } from 'libs/resources'; export const getJobListItemResources = (job: IJob) => { - return job.job_submissions?.[job.job_submissions.length - 1]?.job_provisioning_data?.instance_type?.resources?.description; + const resources = job.job_submissions?.[job.job_submissions.length - 1]?.job_provisioning_data?.instance_type?.resources; + return resources ? formatResources(resources) : '-'; }; export const getJobListItemSpot = (job: IJob) => { @@ -31,7 +34,7 @@ export const getJobListItemRegion = (job: IJob) => { }; export const getJobListItemBackend = (job: IJob) => { - return job.job_submissions?.[job.job_submissions.length - 1]?.job_provisioning_data?.backend ?? '-'; + return formatBackend(job.job_submissions?.[job.job_submissions.length - 1]?.job_provisioning_data?.backend); }; export const getJobSubmittedAt = (job: IJob) => { diff --git a/frontend/src/pages/Runs/Details/RunDetails/index.tsx b/frontend/src/pages/Runs/Details/RunDetails/index.tsx index e899ad0bc3..408d4cb16b 100644 --- a/frontend/src/pages/Runs/Details/RunDetails/index.tsx +++ b/frontend/src/pages/Runs/Details/RunDetails/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { get as _get } from 'lodash'; import { format } from 'date-fns'; import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, StatusIndicator } from 'components'; @@ -29,7 +28,8 @@ import { getRunListItemRegion, getRunListItemResources, getRunListItemSchedule, - getRunListItemSpot, + getRunListItemServiceUrl, + getRunListItemSpotLabelKey, } from '../../List/helpers'; import { EventsList } from '../Events/List'; import { JobList } from '../Jobs/List'; @@ -93,35 +93,14 @@ export const RunDetails = () => { -
- {t('projects.run.repo')} - -
- {_get(runData.run_spec.repo_data, 'repo_name', _get(runData.run_spec.repo_data, 'repo_dir', '-'))} -
-
- -
- {t('projects.run.hub_user_name')} - -
- {runData.user} -
-
-
{t('projects.run.configuration')}
{runData.run_spec.configuration_path}
- {t('projects.run.submitted_at')} -
{format(new Date(runData.submitted_at), DATE_TIME_FORMAT)}
-
- -
- {t('projects.run.finished_at')} -
{finishedAt ? format(new Date(finishedAt), DATE_TIME_FORMAT) : '-'}
+ {t('projects.run.resources')} +
{getRunListItemResources(runData)}
@@ -145,18 +124,26 @@ export const RunDetails = () => { )}
- {t('projects.run.error')} -
{getRunError(runData) ?? '-'}
+ {t('projects.run.hub_user_name')} + +
+ {runData.user} +
- {t('projects.run.priority')} -
{getRunPriority(runData)}
+ {t('projects.run.submitted_at')} +
{format(new Date(runData.submitted_at), DATE_TIME_FORMAT)}
- {t('projects.run.cost')} -
${runData.cost}
+ {t('projects.run.finished_at')} +
{finishedAt ? format(new Date(finishedAt), DATE_TIME_FORMAT) : '-'}
+
+ +
+ {t('projects.run.error')} +
{getRunError(runData) ?? '-'}
@@ -165,8 +152,13 @@ export const RunDetails = () => {
- {t('projects.run.resources')} -
{getRunListItemResources(runData)}
+ {t('projects.run.cost')} +
${runData.cost}
+
+ +
+ {t('projects.run.spot')} +
{t(getRunListItemSpotLabelKey(runData))}
@@ -180,13 +172,13 @@ export const RunDetails = () => {
- {t('projects.run.instance_id')} -
{getRunListItemInstanceId(runData)}
+ {t('projects.run.priority')} +
{getRunPriority(runData)}
- {t('projects.run.spot')} -
{getRunListItemSpot(runData)}
+ {t('projects.run.instance_id')} +
{getRunListItemInstanceId(runData)}
diff --git a/frontend/src/pages/Runs/List/Preferences/consts.ts b/frontend/src/pages/Runs/List/Preferences/consts.ts index bffa6b83f6..1e95dfb706 100644 --- a/frontend/src/pages/Runs/List/Preferences/consts.ts +++ b/frontend/src/pages/Runs/List/Preferences/consts.ts @@ -5,21 +5,21 @@ export const DEFAULT_PREFERENCES: CollectionPreferencesProps.Preferences = { contentDisplay: [ { id: 'run_name', visible: true }, { id: 'resources', visible: true }, - { id: 'spot', visible: true }, + { id: 'status', visible: true }, { id: 'hub_user_name', visible: true }, - { id: 'price', visible: true }, { id: 'submitted_at', visible: true }, { id: 'finished_at', visible: true }, - { id: 'status', visible: true }, { id: 'error', visible: true }, + { id: 'price', visible: true }, { id: 'cost', visible: true }, + { id: 'spot', visible: true }, + { id: 'backend', visible: true }, + { id: 'region', visible: true }, // hidden by default { id: 'priority', visible: false }, { id: 'project', visible: false }, { id: 'repo', visible: false }, { id: 'instance', visible: false }, - { id: 'region', visible: false }, - { id: 'backend', visible: false }, ], wrapLines: false, stripedRows: false, diff --git a/frontend/src/pages/Runs/List/helpers.ts b/frontend/src/pages/Runs/List/helpers.ts index 4d9918bc7a..d6eed85d94 100644 --- a/frontend/src/pages/Runs/List/helpers.ts +++ b/frontend/src/pages/Runs/List/helpers.ts @@ -1,6 +1,8 @@ import { groupBy as _groupBy } from 'lodash'; import { getBaseUrl } from 'App/helpers'; +import { formatBackend } from 'libs/fleet'; +import { formatResources } from 'libs/resources'; import { finishedJobs, finishedRunStatuses } from '../constants'; import { getJobStatus } from '../Details/Jobs/List/helpers'; @@ -14,7 +16,8 @@ export const getRunListItemResources = (run: IRun) => { return '-'; } - return run.latest_job_submission?.job_provisioning_data?.instance_type?.resources?.description ?? '-'; + const resources = run.latest_job_submission?.job_provisioning_data?.instance_type?.resources; + return resources ? formatResources(resources) : '-'; }; export const getRunListItemSpotLabelKey = (run: IRun) => { @@ -84,7 +87,7 @@ export const getRunListItemBackend = (run: IRun) => { return '-'; } - return run.latest_job_submission?.job_provisioning_data?.backend ?? '-'; + return formatBackend(run.latest_job_submission?.job_provisioning_data?.backend); }; export const getRunListItemServiceUrl = (run: IRun) => { diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 206eecd54e..1ee08eec9c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -14,7 +14,10 @@ import { FleetAdd, FleetDetails, FleetList } from 'pages/Fleets'; import { EventsList as FleetEventsList } from 'pages/Fleets/Details/Events'; import { FleetDetails as FleetDetailsGeneral } from 'pages/Fleets/Details/FleetDetails'; import { FleetInspect } from 'pages/Fleets/Details/Inspect'; -import { InstanceList } from 'pages/Instances'; +import { InstanceDetailsPage, InstanceList } from 'pages/Instances'; +import { InstanceDetails } from 'pages/Instances/Details/InstanceDetails'; +import { EventsList as InstanceEventsList } from 'pages/Instances/Details/Events'; +import { InstanceInspect } from 'pages/Instances/Details/Inspect'; import { ModelsList } from 'pages/Models'; import { ModelDetails } from 'pages/Models/Details'; import { CreateProjectWizard, ProjectAdd, ProjectDetails, ProjectEvents, ProjectList, ProjectSettings } from 'pages/Project'; @@ -234,6 +237,24 @@ export const router = createBrowserRouter([ path: ROUTES.INSTANCES.LIST, element: , }, + { + path: ROUTES.INSTANCES.DETAILS.TEMPLATE, + element: , + children: [ + { + index: true, + element: , + }, + { + path: ROUTES.INSTANCES.DETAILS.EVENTS.TEMPLATE, + element: , + }, + { + path: ROUTES.INSTANCES.DETAILS.INSPECT.TEMPLATE, + element: , + }, + ], + }, // Volumes { diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 45458d5359..e5d06ef942 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -165,6 +165,21 @@ export const ROUTES = { INSTANCES: { LIST: '/instances', + DETAILS: { + TEMPLATE: `/projects/:projectName/instances/:instanceId`, + FORMAT: (projectName: string, instanceId: string) => + buildRoute(ROUTES.INSTANCES.DETAILS.TEMPLATE, { projectName, instanceId }), + EVENTS: { + TEMPLATE: `/projects/:projectName/instances/:instanceId/events`, + FORMAT: (projectName: string, instanceId: string) => + buildRoute(ROUTES.INSTANCES.DETAILS.EVENTS.TEMPLATE, { projectName, instanceId }), + }, + INSPECT: { + TEMPLATE: `/projects/:projectName/instances/:instanceId/inspect`, + FORMAT: (projectName: string, instanceId: string) => + buildRoute(ROUTES.INSTANCES.DETAILS.INSPECT.TEMPLATE, { projectName, instanceId }), + }, + }, }, VOLUMES: { diff --git a/frontend/src/services/instance.ts b/frontend/src/services/instance.ts index e483084b1a..a6107e3d9b 100644 --- a/frontend/src/services/instance.ts +++ b/frontend/src/services/instance.ts @@ -25,6 +25,18 @@ export const instanceApi = createApi({ result ? [...result.map(({ name }) => ({ type: 'Instance' as const, id: name })), 'Instances'] : ['Instances'], }), + getInstanceDetails: builder.query({ + query: ({ projectName, instanceId }) => { + return { + url: API.INSTANCES.DETAILS(projectName), + method: 'POST', + body: { id: instanceId }, + }; + }, + + providesTags: (result) => (result ? [{ type: 'Instance' as const, id: result.name }] : []), + }), + deleteInstances: builder.mutation< void, { projectName: IProject['project_name']; fleetName: string; instancesNums: number[] } @@ -42,4 +54,4 @@ export const instanceApi = createApi({ }), }); -export const { useLazyGetInstancesQuery, useDeleteInstancesMutation } = instanceApi; +export const { useLazyGetInstancesQuery, useGetInstanceDetailsQuery, useDeleteInstancesMutation } = instanceApi; diff --git a/frontend/src/types/instance.d.ts b/frontend/src/types/instance.d.ts index 5a661f26da..585f4f5093 100644 --- a/frontend/src/types/instance.d.ts +++ b/frontend/src/types/instance.d.ts @@ -14,6 +14,8 @@ declare type TInstanceStatus = | 'terminating' | 'terminated'; +declare type THealthStatus = 'healthy' | 'warning' | 'failure'; + declare interface IInstance { id: string; fleet_name: string; @@ -30,7 +32,15 @@ declare interface IInstance { job_status: TJobStatus | null; hostname: string; status: TInstanceStatus; + unreachable: boolean; + health_status: THealthStatus; + termination_reason: string | null; + termination_reason_message: string | null; created: DateTime; + finished_at: DateTime | null; region: string; + availability_zone: string | null; price: number | null; + total_blocks: number | null; + busy_blocks: number; } diff --git a/frontend/src/types/run.d.ts b/frontend/src/types/run.d.ts index e624a63e3b..3eac746218 100644 --- a/frontend/src/types/run.d.ts +++ b/frontend/src/types/run.d.ts @@ -269,7 +269,9 @@ declare interface IResources { spot: boolean; disk?: IDisk; + cpu_arch?: string | null; + /** @deprecated Use formatResources() from libs/resources instead. Remove in 0.21. */ description?: string; } diff --git a/src/dstack/_internal/core/models/instances.py b/src/dstack/_internal/core/models/instances.py index 012916f97e..7eccee8b69 100644 --- a/src/dstack/_internal/core/models/instances.py +++ b/src/dstack/_internal/core/models/instances.py @@ -1,10 +1,10 @@ import datetime from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Annotated, Any, Dict, List, Optional from uuid import UUID import gpuhunt -from pydantic import root_validator +from pydantic import Field, root_validator from dstack._internal.core.models.backends.base import BackendType from dstack._internal.core.models.common import ( @@ -56,8 +56,11 @@ class Resources(CoreModel): spot: bool disk: Disk = Disk(size_mib=102400) # the default value (100GB) for backward compatibility cpu_arch: Optional[gpuhunt.CPUArchitecture] = None - # TODO: make description a computed field after migrating to pydanticV2 - description: str = "" + # Deprecated: description is now generated client-side. TODO: remove in 0.21. + description: Annotated[ + str, + Field(description="Deprecated: generated client-side. Will be removed in 0.21."), + ] = "" @root_validator def _description(cls, values) -> Dict: @@ -339,6 +342,7 @@ class Instance(CoreModel): termination_reason: Optional[str] = None termination_reason_message: Optional[str] = None created: datetime.datetime + finished_at: Optional[datetime.datetime] = None region: Optional[str] = None availability_zone: Optional[str] = None price: Optional[float] = None diff --git a/src/dstack/_internal/server/services/instances.py b/src/dstack/_internal/server/services/instances.py index f37e1c9682..046f092c03 100644 --- a/src/dstack/_internal/server/services/instances.py +++ b/src/dstack/_internal/server/services/instances.py @@ -228,6 +228,7 @@ def instance_model_to_instance(instance_model: InstanceModel) -> Instance: ), termination_reason_message=instance_model.termination_reason_message, created=instance_model.created_at, + finished_at=instance_model.finished_at, total_blocks=instance_model.total_blocks, busy_blocks=instance_model.busy_blocks, ) diff --git a/src/tests/_internal/server/routers/test_fleets.py b/src/tests/_internal/server/routers/test_fleets.py index fef712acae..1a250612ba 100644 --- a/src/tests/_internal/server/routers/test_fleets.py +++ b/src/tests/_internal/server/routers/test_fleets.py @@ -411,6 +411,7 @@ async def test_creates_fleet(self, test_db, session: AsyncSession, client: Async "termination_reason": None, "termination_reason_message": None, "created": "2023-01-02T03:04:00+00:00", + "finished_at": None, "backend": None, "region": None, "availability_zone": None, @@ -554,6 +555,7 @@ async def test_creates_ssh_fleet(self, test_db, session: AsyncSession, client: A "termination_reason": None, "termination_reason_message": None, "created": "2023-01-02T03:04:00+00:00", + "finished_at": None, "region": "remote", "availability_zone": None, "price": 0.0, @@ -733,6 +735,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A "termination_reason": "terminated_by_user", "termination_reason_message": None, "created": "2023-01-02T03:04:00+00:00", + "finished_at": None, "region": "remote", "availability_zone": None, "price": 0.0, @@ -767,6 +770,7 @@ async def test_updates_ssh_fleet(self, test_db, session: AsyncSession, client: A "termination_reason": None, "termination_reason_message": None, "created": "2023-01-02T03:04:00+00:00", + "finished_at": None, "region": "remote", "availability_zone": None, "price": 0.0,