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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ venv/
.envrc
uv.lock

.playwright-mcp

/runner/cmd/shim/shim
/runner/cmd/runner/runner

Expand Down
1 change: 1 addition & 0 deletions frontend/product.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const oss = {
hasEvents: false,
hasBilling: false,
hasPresets: false,
/** Whether this product allows templates, even when none are configured. */
hasTemplates: false,
isSky: false,
};
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,10 @@
"availability_not_available": "Not available",
"availability_no_quota": "No quota",
"availability_no_balance": "No balance",
"availability_busy": "Busy",
"availability_idle": "Idle",
"truncated": "Showing {{shown}} of {{total}} offers. Use filters to narrow the results.",
"terminated_fleet": "Offers are unavailable for a terminated fleet.",
"groupBy": "Group by properties",
"region": "Region",
"count": "Count",
Expand All @@ -587,7 +591,8 @@
"empty_message_title_select_groupBy": "Select a group by",
"empty_message_text_select_groupBy": "Use the field above to select a group by",
"empty_message_title": "No offers",
"empty_message_text": "A backend may not be configured for this project.",
"empty_message_text": "No offers matching backends or SSH fleets. Check configured backends.",
"empty_message_text_fleet": "No offers matching fleet requirements. Check configured backends.",
"nomatch_message_title": "No matches",
"nomatch_message_text": "We can't find a match. Try to clear filter."
},
Expand Down
107 changes: 48 additions & 59 deletions frontend/src/pages/Fleets/Details/FleetDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ 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 { Box, ColumnLayout, Container, Header, Loader, NavigateLink, SpaceBetween, StatusIndicator } from 'components';

import { DATE_TIME_FORMAT } from 'consts';
import {
formatFleetBackend,
formatFleetResources,
getFleetInstancesLinkText,
getFleetPrice,
getFleetStatusIconType,
} from 'libs/fleet';
import { formatFleetBackend, formatFleetResources, getFleetPrice, getFleetStatusIconType } from 'libs/fleet';
import { ROUTES } from 'routes';
import { useGetFleetDetailsQuery } from 'services/fleet';

import { InstanceList } from 'pages/Instances/List';

export const FleetDetails = () => {
const { t } = useTranslation();
const params = useParams();
Expand All @@ -41,73 +37,66 @@ export const FleetDetails = () => {
)}

{data && (
<Container header={<Header variant="h2">{t('common.general')}</Header>}>
<ColumnLayout columns={4} variant="text-grid">
<div>
<Box variant="awsui-key-label">{t('fleets.fleet')}</Box>
<div>{data.name}</div>
</div>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.status')}</Box>

<SpaceBetween size="l">
<Container header={<Header variant="h2">{t('common.general')}</Header>}>
<ColumnLayout columns={4} variant="text-grid">
<div>
<StatusIndicator type={getFleetStatusIconType(data.status)}>
{t(`fleets.statuses.${data.status}`)}
</StatusIndicator>
<Box variant="awsui-key-label">{t('fleets.fleet')}</Box>
<div>{data.name}</div>
</div>
</div>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.project')}</Box>

<div>
<NavigateLink href={ROUTES.PROJECT.DETAILS.FORMAT(data.project_name)}>
{data.project_name}
</NavigateLink>
</div>
</div>
<Box variant="awsui-key-label">{t('fleets.instances.status')}</Box>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.backend')}</Box>
<div>{formatFleetBackend(data.spec.configuration)}</div>
</div>
<div>
<StatusIndicator type={getFleetStatusIconType(data.status)}>
{t(`fleets.statuses.${data.status}`)}
</StatusIndicator>
</div>
</div>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.resources')}</Box>
<div>
{data.spec.configuration.ssh_config
? '-'
: formatFleetResources(data.spec.configuration.resources)}
<Box variant="awsui-key-label">{t('fleets.instances.project')}</Box>

<div>
<NavigateLink href={ROUTES.PROJECT.DETAILS.FORMAT(data.project_name)}>
{data.project_name}
</NavigateLink>
</div>
</div>
</div>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.title')}</Box>
<div>
<Box variant="awsui-key-label">{t('fleets.instances.backend')}</Box>
<div>{formatFleetBackend(data.spec.configuration)}</div>
</div>

<div>
<NavigateLink href={ROUTES.INSTANCES.LIST + `?fleet_ids=${data.id}`}>
{getFleetInstancesLinkText(data)}
</NavigateLink>
<Box variant="awsui-key-label">{t('fleets.instances.resources')}</Box>
<div>
{data.spec.configuration.ssh_config
? '-'
: formatFleetResources(data.spec.configuration.resources)}
</div>
</div>
</div>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.created')}</Box>
<div>{format(new Date(data.created_at), DATE_TIME_FORMAT)}</div>
</div>
<div>
<Box variant="awsui-key-label">{t('fleets.instances.created')}</Box>
<div>{format(new Date(data.created_at), DATE_TIME_FORMAT)}</div>
</div>

<div>
<Box variant="awsui-key-label">{t('fleets.instances.price')}</Box>
<div>
{(() => {
const p = getFleetPrice(data);
return typeof p === 'number' ? `$${p}` : '-';
})()}
<Box variant="awsui-key-label">{t('fleets.instances.price')}</Box>
<div>
{(() => {
const p = getFleetPrice(data);
return typeof p === 'number' ? `$${p}` : '-';
})()}
</div>
</div>
</div>
</ColumnLayout>
</Container>
</ColumnLayout>
</Container>
<InstanceList key={data.id} fleetId={data.id} />
</SpaceBetween>
)}
</>
);
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/pages/Fleets/Details/Offers/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';

import { Alert, Header, Loader } from 'components';

import { getServerError } from 'libs';
import { useGetFleetDetailsQuery } from 'services/fleet';

import { OfferList } from 'pages/Offers/List';

export const FleetOffers = () => {
const { t } = useTranslation();
const { projectName = '', fleetId = '' } = useParams();
const { currentData: fleet, error } = useGetFleetDetailsQuery({ projectName, fleetId });

if (error) {
return <Alert type="error">{t('common.server_error', { error: getServerError(error) })}</Alert>;
}

if (!fleet) {
return <Loader />;
}

// Offer APIs identify active fleets by name; a terminated fleet's name may have been reused.
if (fleet.status === 'terminated') {
return <Alert type="info">{t('offer.terminated_fleet')}</Alert>;
}

return (
<OfferList
key={`${projectName}/${fleetId}`}
permanentFilters={{ project_name: projectName, fleet: fleet.name }}
allowUngrouped
header={<Header variant="h2">{t('offer.title')}</Header>}
/>
);
};
6 changes: 6 additions & 0 deletions frontend/src/pages/Fleets/Details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button, ContentLayout, DetailsHeader, Tabs } from 'components';

enum CodeTab {
Details = 'details',
Offers = 'offers',
Events = 'events',
Inspect = 'inspect',
}
Expand Down Expand Up @@ -92,6 +93,11 @@ export const FleetDetails: React.FC = () => {
id: CodeTab.Details,
href: ROUTES.FLEETS.DETAILS.FORMAT(paramProjectName, paramFleetId),
},
{
label: t('offer.title'),
id: CodeTab.Offers,
href: ROUTES.FLEETS.DETAILS.OFFERS.FORMAT(paramProjectName, paramFleetId),
},
{
label: 'Events',
id: CodeTab.Events,
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/pages/Instances/List/hooks/useEmptyMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ export const useEmptyMessages = ({
title={t('fleets.instances.empty_message_title')}
message={t('fleets.instances.empty_message_text')}
>
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
{t('common.clearFilter')}
</Button>
{!isDisabledClearFilter && <Button onClick={clearFilter}>{t('common.clearFilter')}</Button>}
</ListEmptyMessage>
);
}, [clearFilter, isDisabledClearFilter]);
Expand All @@ -31,9 +29,7 @@ export const useEmptyMessages = ({
title={t('fleets.instances.nomatch_message_title')}
message={t('fleets.instances.nomatch_message_text')}
>
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
{t('common.clearFilter')}
</Button>
{!isDisabledClearFilter && <Button onClick={clearFilter}>{t('common.clearFilter')}</Button>}
</ListEmptyMessage>
);
}, [clearFilter, isDisabledClearFilter]);
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/pages/Instances/List/hooks/useFilters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** @jest-environment node */
import React from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { act, create, ReactTestRenderer } from 'react-test-renderer';

import { useFilters } from './useFilters';

jest.mock('hooks', () => ({
useLocalStorageState: (_key: string, value: boolean) => jest.requireActual('react').useState(value),
}));
jest.mock('services/project', () => ({ useLazyGetProjectsQuery: () => [jest.fn()] }));

let filters: ReturnType<typeof useFilters>;
let search: string;
let renderer: ReactTestRenderer;

const Harness = ({ fleetId }: { fleetId?: string }) => {
filters = useFilters(fleetId);
search = useLocation().search;
return null;
};

const renderFilters = (url: string, fleetId?: string) => {
act(() => {
renderer = create(
<MemoryRouter initialEntries={[url]}>
<Harness fleetId={fleetId} />
</MemoryRouter>,
);
});
};

afterEach(() => act(() => renderer.unmount()));

test('embedded instances stay scoped to the fleet without changing the fleet page URL', () => {
const query = '?project_names=other&fleet_ids=other&gpu_count=0';
renderFilters(`/${query}`, 'fixed-fleet');
expect(filters.filteringRequestParams).toEqual({
fleet_ids: ['fixed-fleet'],
only_active: true,
include_imported: true,
});

act(() => filters.onChangeOnlyActive!({ detail: { checked: false } } as never));
act(() => filters.clearFilter());
expect(filters.filteringRequestParams).toEqual({
fleet_ids: ['fixed-fleet'],
only_active: false,
include_imported: true,
});
expect(search).toBe(query);
});

test('global instances still read and clear URL filters', () => {
renderFilters('/?project_names=main&fleet_ids=my-fleet');
expect(filters.filteringRequestParams).toMatchObject({ project_names: ['main'], fleet_ids: ['my-fleet'] });
act(() => filters.clearFilter());
expect(filters.filteringRequestParams).toEqual({ only_active: true, include_imported: true });
expect(search).toBe('');
});
13 changes: 7 additions & 6 deletions frontend/src/pages/Instances/List/hooks/useFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ const filterKeys: Record<string, RequestParamsKeys> = {

const limit = 100;

export const useFilters = () => {
export const useFilters = (fleetId?: string) => {
const [searchParams, setSearchParams] = useSearchParams();
const [onlyActive, setOnlyActive] = useLocalStorageState('instance-list-filter-only-active', true);
const [dynamicFilteringOptions, setDynamicFilteringOptions] = useState<PropertyFilterProps.FilteringOption[]>([]);
const [filteringStatusType, setFilteringStatusType] = useState<PropertyFilterProps.StatusType | undefined>();
const [getProjects] = useLazyGetProjectsQuery();

const [propertyFilterQuery, setPropertyFilterQuery] = useState<PropertyFilterProps.Query>(() => {
return requestParamsToTokens<RequestParamsKeys>({ searchParams, filterKeys });
return fleetId ? EMPTY_QUERY : requestParamsToTokens<RequestParamsKeys>({ searchParams, filterKeys });
});

const clearFilter = () => {
setSearchParams({});
if (!fleetId) setSearchParams({});
setPropertyFilterQuery(EMPTY_QUERY);
};

Expand All @@ -61,7 +61,7 @@ export const useFilters = () => {
const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => {
const { tokens, operation } = detail;

setSearchParams(tokensToSearchParams<RequestParamsKeys>(tokens, onlyActive));
if (!fleetId) setSearchParams(tokensToSearchParams<RequestParamsKeys>(tokens, onlyActive));

setPropertyFilterQuery({
operation,
Expand All @@ -81,12 +81,13 @@ export const useFilters = () => {

return {
...params,
...(fleetId ? { fleet_ids: [fleetId] } : {}),
only_active: onlyActive,
include_imported: true,
} as Partial<TInstanceListRequestParams>;
}, [propertyFilterQuery, onlyActive]);
}, [propertyFilterQuery, onlyActive, fleetId]);

const isDisabledClearFilter = !propertyFilterQuery.tokens.length && !onlyActive;
const isDisabledClearFilter = !propertyFilterQuery.tokens.length;

const handleLoadItems: PropertyFilterProps['onLoadItems'] = async ({ detail: { filteringProperty, filteringText } }) => {
setDynamicFilteringOptions([]);
Expand Down
Loading
Loading