Skip to content

Commit 03f6838

Browse files
[UI] Add Instance details page (#3614)
* Add Instance details page and improve Fleet/Run UI - Add Instance details page with Details, Events, and Inspect tabs - Add Name column (hyperlink) to Instance list, drop Instance num column - Add Health column to Instance list and details (icon-only status indicator) - Add hyperlinks from Events page instance targets to Instance details - Expose finished_at on Instance API model - Add Backend and Resources columns to Fleet list and details - Generate resources description client-side, deprecate server-side description - Display 'ssh' instead of 'remote' for SSH fleet backends globally - Remove misleading Price from Fleet and Instance list/details - Fleet list: rename Fleet→Name column, Started→Created - Run details: remove Configuration/Repository, reorder to match list, Spot as Yes/No - Instance list: reorder columns (Status/Health before Hostname), Hostname label fix Closes #2999 AI Assistance: Claude Made-with: Cursor * #2999 small fixes * Fix fleet tests for Instance.finished_at field Add finished_at to expected Instance dicts in fleet API tests. Made-with: Cursor * Address review feedback - Restore Configuration property on Run details page - Add Field(description) deprecation notice to Resources.description - Use formatBackend() in Run and Job helpers instead of inline logic - Restore Price column/field to Instance list and details Made-with: Cursor * Add Finished column to Instance list, replace Health with Error, restore Fleet Price - Add Finished column to Instance list - Move Price after Backend in Instance list - Replace Health column/field with Error (only shows non-healthy values) - Error field always visible in Instance details (shows - when healthy) - Move Blocks before Hostname in Instance details - Restore Price to Fleet list and Fleet details Made-with: Cursor --------- Co-authored-by: Oleg Vavilov <vavilovolegik@gmail.com>
1 parent 597d766 commit 03f6838

File tree

29 files changed

+829
-100
lines changed

29 files changed

+829
-100
lines changed

frontend/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export const API = {
171171
INSTANCES: {
172172
BASE: () => `${API.BASE()}/instances`,
173173
LIST: () => `${API.INSTANCES.BASE()}/list`,
174+
DETAILS: (projectName: IProject['project_name']) =>
175+
`${API.BASE()}/project/${projectName}/instances/get`,
174176
},
175177

176178
SERVER: {

frontend/src/libs/fleet.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { isEqual } from 'lodash';
22
import { StatusIndicatorProps } from '@cloudscape-design/components';
33

4+
export const formatBackend = (backend: TBackendType | string | null | undefined): string => {
5+
if (!backend) return '-';
6+
if (backend === 'remote') return 'ssh';
7+
return backend;
8+
};
9+
410
export const getStatusIconType = (status: IInstance['status']): StatusIndicatorProps['type'] => {
511
switch (status) {
612
case 'pending':
@@ -55,6 +61,90 @@ const getInstanceFields = (instance: IInstance) => ({
5561
spot: instance.instance_type?.resources.spot,
5662
});
5763

64+
const formatRange = (min: unknown, max: unknown, suffix = ''): string => {
65+
if (min == null && max == null) return '';
66+
if (min === max) return `${min}${suffix}`;
67+
if (max == null) return `${min}${suffix}..`;
68+
if (min == null) return `..${max}${suffix}`;
69+
return `${min}${suffix}..${max}${suffix}`;
70+
};
71+
72+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73+
const formatCpu = (cpu: any): string | null => {
74+
if (!cpu) return null;
75+
if (typeof cpu === 'number') return `cpu=${cpu}`;
76+
if (cpu.min != null || cpu.max != null) return `cpu=${formatRange(cpu.min, cpu.max)}`;
77+
const arch = cpu.arch;
78+
const count = cpu.count;
79+
if (!count) return null;
80+
const prefix = arch === 'arm' ? 'arm:' : '';
81+
return `cpu=${prefix}${formatRange(count.min, count.max)}`;
82+
};
83+
84+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85+
const formatGpu = (gpu: any): string | null => {
86+
if (!gpu) return null;
87+
const count = gpu.count;
88+
if (!count || (count.min === 0 && (count.max == null || count.max === 0))) return null;
89+
90+
const gpuParts: string[] = [];
91+
92+
if (gpu.memory) {
93+
const memStr = formatRange(gpu.memory.min, gpu.memory.max, 'GB');
94+
if (memStr) gpuParts.push(memStr);
95+
}
96+
97+
const countStr = formatRange(count.min, count.max);
98+
if (countStr) gpuParts.push(countStr);
99+
100+
if (gpu.total_memory) {
101+
const tmStr = formatRange(gpu.total_memory.min, gpu.total_memory.max, 'GB');
102+
if (tmStr) gpuParts.push(tmStr);
103+
}
104+
105+
let label: string;
106+
if (gpu.name && gpu.name.length > 0) {
107+
label = gpu.name.join(',');
108+
} else if (gpu.vendor) {
109+
label = gpu.vendor;
110+
} else {
111+
label = '';
112+
}
113+
114+
return 'gpu=' + [label, ...gpuParts].filter(Boolean).join(':');
115+
};
116+
117+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
118+
export const formatFleetResources = (resources: any): string => {
119+
if (!resources) return '-';
120+
121+
const parts: string[] = [];
122+
123+
const cpuStr = formatCpu(resources.cpu);
124+
if (cpuStr) parts.push(cpuStr);
125+
126+
if (resources.memory) {
127+
const memStr = formatRange(resources.memory.min, resources.memory.max, 'GB');
128+
if (memStr) parts.push(`mem=${memStr}`);
129+
}
130+
131+
if (resources.disk?.size) {
132+
const diskStr = formatRange(resources.disk.size.min, resources.disk.size.max, 'GB');
133+
if (diskStr) parts.push(`disk=${diskStr}`);
134+
}
135+
136+
const gpuStr = formatGpu(resources.gpu);
137+
if (gpuStr) parts.push(gpuStr);
138+
139+
return parts.length > 0 ? parts.join(' ') : '-';
140+
};
141+
142+
export const formatFleetBackend = (config: IFleetConfigurationRequest): string => {
143+
if (config.ssh_config) return 'ssh';
144+
if (!config.backends || config.backends.length === 0) return '-';
145+
return config.backends.map((b) => formatBackend(b)).join(', ');
146+
};
147+
58148
export const getFleetInstancesLinkText = (fleet: IFleet): string => {
59149
const instances = fleet.instances.filter((i) => i.status !== 'terminated');
60150
const hasPending = instances.some((i) => i.status === 'pending');
@@ -68,7 +158,7 @@ export const getFleetInstancesLinkText = (fleet: IFleet): string => {
68158
if (isSameInstances)
69159
return `${instances.length}x ${instances[0].instance_type?.name}${
70160
instances[0].instance_type?.resources.spot ? ' (spot)' : ''
71-
} @ ${instances[0].backend} (${instances[0].region})`;
161+
} @ ${formatBackend(instances[0].backend)} (${instances[0].region})`;
72162

73163
return `${instances.length} instances`;
74164
};

frontend/src/libs/instance.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { StatusIndicatorProps } from '@cloudscape-design/components';
2+
3+
export const prettyEnumValue = (value: string): string => {
4+
return value.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
5+
};
6+
7+
export const getHealthStatusIconType = (healthStatus: THealthStatus): StatusIndicatorProps['type'] => {
8+
switch (healthStatus) {
9+
case 'healthy':
10+
return 'success';
11+
case 'warning':
12+
return 'warning';
13+
case 'failure':
14+
return 'error';
15+
default:
16+
return 'info';
17+
}
18+
};
19+
20+
export const formatInstanceStatusText = (instance: IInstance): string => {
21+
const status = instance.status;
22+
23+
if (
24+
(status === 'idle' || status === 'busy') &&
25+
instance.total_blocks !== null &&
26+
instance.total_blocks > 1
27+
) {
28+
return `${instance.busy_blocks}/${instance.total_blocks} Busy`;
29+
}
30+
31+
return prettyEnumValue(status);
32+
};

frontend/src/libs/resources.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const mibToGB = (mib: number): string => `${Math.round(mib / 1024)}GB`;
2+
3+
export const formatResources = (resources: IResources, includeSpot = true): string => {
4+
const parts: string[] = [];
5+
6+
if (resources.cpus > 0) {
7+
const archPrefix = resources.cpu_arch === 'arm' ? 'arm:' : '';
8+
parts.push(`cpu=${archPrefix}${resources.cpus}`);
9+
}
10+
11+
if (resources.memory_mib > 0) {
12+
parts.push(`mem=${mibToGB(resources.memory_mib)}`);
13+
}
14+
15+
if (resources.disk && resources.disk.size_mib > 0) {
16+
parts.push(`disk=${mibToGB(resources.disk.size_mib)}`);
17+
}
18+
19+
if (resources.gpus.length > 0) {
20+
const gpu = resources.gpus[0];
21+
const gpuParts: string[] = [];
22+
23+
if (gpu.memory_mib > 0) {
24+
gpuParts.push(mibToGB(gpu.memory_mib));
25+
}
26+
27+
gpuParts.push(String(resources.gpus.length));
28+
29+
parts.push('gpu=' + [gpu.name, ...gpuParts].filter(Boolean).join(':'));
30+
}
31+
32+
let output = parts.join(' ');
33+
34+
if (includeSpot && resources.spot) {
35+
output += ' (spot)';
36+
}
37+
38+
return output || '-';
39+
};

frontend/src/libs/run.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { get as _get } from 'lodash';
22
import { StatusIndicatorProps } from '@cloudscape-design/components';
33

44
import { capitalize } from 'libs';
5+
import { formatResources } from 'libs/resources';
56

67
import { finishedRunStatuses } from '../pages/Runs/constants';
78
import { getJobProbesStatuses } from '../pages/Runs/Details/Jobs/List/helpers';
@@ -99,7 +100,9 @@ export const getExtendedModelFromRun = (run: IRun): IModelExtended | null => {
99100
project_name: run.project_name,
100101
run_name: run?.run_spec.run_name ?? 'No run name',
101102
user: run.user,
102-
resources: run.latest_job_submission?.job_provisioning_data?.instance_type?.resources?.description ?? null,
103+
resources: run.latest_job_submission?.job_provisioning_data?.instance_type?.resources
104+
? formatResources(run.latest_job_submission.job_provisioning_data.instance_type.resources)
105+
: null,
103106
price: run.latest_job_submission?.job_provisioning_data?.price ?? null,
104107
submitted_at: run.submitted_at,
105108
repository: getRepoNameFromRun(run),

frontend/src/locale/en.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@
586586
"button_title": "Create a fleet"
587587
},
588588
"fleet": "Fleet",
589+
"fleet_column_name": "Name",
589590
"fleet_placeholder": "Filtering by fleet",
590591
"fleet_name": "Fleet name",
591592
"total_instances": "Number of instances",
@@ -615,12 +616,12 @@
615616
"empty_message_text": "No instances to display.",
616617
"nomatch_message_title": "No matches",
617618
"nomatch_message_text": "We can't find a match.",
618-
"instance_name": "Instance",
619-
"instance_num": "Instance num",
619+
"instance_name": "Name",
620+
"instance_num": "Num",
620621
"created": "Created",
621622
"status": "Status",
622623
"project": "Project",
623-
"hostname": "Host name",
624+
"hostname": "Hostname",
624625
"instance_type": "Type",
625626
"statuses": {
626627
"pending": "Pending",
@@ -635,7 +636,12 @@
635636
"region": "Region",
636637
"spot": "Spot",
637638
"started": "Started",
638-
"price": "Price"
639+
"finished_at": "Finished",
640+
"price": "Price",
641+
"termination_reason": "Termination reason",
642+
"health": "Health",
643+
"blocks": "Blocks",
644+
"inspect": "Inspect"
639645
},
640646
"edit": {
641647
"name": "Name",

frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ export const useColumnsDefinitions = () => {
7777
{target.project_name}
7878
</NavigateLink>
7979
)}
80-
/{target.name}
80+
/
81+
<NavigateLink
82+
href={ROUTES.INSTANCES.DETAILS.FORMAT(target.project_name ?? '', target.id)}
83+
>
84+
{target.name}
85+
</NavigateLink>
8186
</div>
8287
);
8388

frontend/src/pages/Fleets/Details/FleetDetails/index.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { format } from 'date-fns';
66
import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, StatusIndicator } from 'components';
77

88
import { DATE_TIME_FORMAT } from 'consts';
9-
import { getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet';
9+
import { formatFleetBackend, formatFleetResources, getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet';
1010
import { ROUTES } from 'routes';
1111
import { useGetFleetDetailsQuery } from 'services/fleet';
1212

@@ -26,14 +26,6 @@ export const FleetDetails = () => {
2626
},
2727
);
2828

29-
const renderPrice = (fleet: IFleet) => {
30-
const price = getFleetPrice(fleet);
31-
32-
if (typeof price === 'number') return `$${price}`;
33-
34-
return '-';
35-
};
36-
3729
return (
3830
<>
3931
{isLoading && (
@@ -70,6 +62,16 @@ export const FleetDetails = () => {
7062
</div>
7163
</div>
7264

65+
<div>
66+
<Box variant="awsui-key-label">{t('fleets.instances.backend')}</Box>
67+
<div>{formatFleetBackend(data.spec.configuration)}</div>
68+
</div>
69+
70+
<div>
71+
<Box variant="awsui-key-label">{t('fleets.instances.resources')}</Box>
72+
<div>{formatFleetResources(data.spec.configuration.resources)}</div>
73+
</div>
74+
7375
<div>
7476
<Box variant="awsui-key-label">{t('fleets.instances.title')}</Box>
7577

@@ -81,13 +83,13 @@ export const FleetDetails = () => {
8183
</div>
8284

8385
<div>
84-
<Box variant="awsui-key-label">{t('fleets.instances.started')}</Box>
86+
<Box variant="awsui-key-label">{t('fleets.instances.created')}</Box>
8587
<div>{format(new Date(data.created_at), DATE_TIME_FORMAT)}</div>
8688
</div>
8789

8890
<div>
8991
<Box variant="awsui-key-label">{t('fleets.instances.price')}</Box>
90-
<div>{renderPrice(data)}</div>
92+
<div>{(() => { const p = getFleetPrice(data); return typeof p === 'number' ? `$${p}` : '-'; })()}</div>
9193
</div>
9294
</ColumnLayout>
9395
</Container>

frontend/src/pages/Fleets/List/hooks.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Button, ListEmptyMessage, NavigateLink, StatusIndicator, TableProps } f
1010
import { DATE_TIME_FORMAT } from 'consts';
1111
import { useProjectFilter } from 'hooks/useProjectFilter';
1212
import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams, tokensToSearchParams } from 'libs/filters';
13-
import { getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet';
13+
import { formatFleetBackend, formatFleetResources, getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet';
1414
import { ROUTES } from 'routes';
1515

1616
export const useEmptyMessages = ({
@@ -51,7 +51,7 @@ export const useColumnsDefinitions = () => {
5151
const columns: TableProps.ColumnDefinition<IFleet>[] = [
5252
{
5353
id: 'fleet_name',
54-
header: t('fleets.fleet'),
54+
header: t('fleets.fleet_column_name'),
5555
cell: (item) => (
5656
<NavigateLink href={ROUTES.FLEETS.DETAILS.FORMAT(item.project_name, item.id)}>{item.name}</NavigateLink>
5757
),
@@ -72,6 +72,16 @@ export const useColumnsDefinitions = () => {
7272
<NavigateLink href={ROUTES.PROJECT.DETAILS.FORMAT(item.project_name)}>{item.project_name}</NavigateLink>
7373
),
7474
},
75+
{
76+
id: 'backend',
77+
header: t('fleets.instances.backend'),
78+
cell: (item) => formatFleetBackend(item.spec.configuration),
79+
},
80+
{
81+
id: 'resources',
82+
header: t('fleets.instances.resources'),
83+
cell: (item) => formatFleetResources(item.spec.configuration.resources),
84+
},
7585
{
7686
id: 'instances',
7787
header: t('fleets.instances.title'),
@@ -82,19 +92,16 @@ export const useColumnsDefinitions = () => {
8292
),
8393
},
8494
{
85-
id: 'started',
86-
header: t('fleets.instances.started'),
95+
id: 'created',
96+
header: t('fleets.instances.created'),
8797
cell: (item) => format(new Date(item.created_at), DATE_TIME_FORMAT),
8898
},
8999
{
90100
id: 'price',
91101
header: t('fleets.instances.price'),
92102
cell: (item) => {
93103
const price = getFleetPrice(item);
94-
95-
if (typeof price === 'number') return `$${price}`;
96-
97-
return '-';
104+
return typeof price === 'number' ? `$${price}` : '-';
98105
},
99106
},
100107
];

0 commit comments

Comments
 (0)