Skip to content
Merged
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
3 changes: 2 additions & 1 deletion frontend/product.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ const oss = {
hasEvents: false,
hasBilling: false,
hasPresets: false,
hasTemplates: false,
isSky: false,
};
const enterprise = { ...oss, id: 'enterprise', name: 'dstack Enterprise', hasEvents: true };
const enterprise = { ...oss, id: 'enterprise', name: 'dstack Enterprise', hasEvents: true, hasTemplates: true };
const factory = { ...enterprise, id: 'factory', name: 'dstack Factory', hasBilling: true, hasPresets: true };
const sky = { ...factory, id: 'sky', name: 'dstack Sky', isSky: true };

Expand Down
15 changes: 10 additions & 5 deletions frontend/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Provider as BusProvider } from 'react-bus';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { RouterProvider } from 'react-router-dom';
import { I18nProvider } from '@cloudscape-design/components/i18n';
import enMessages from '@cloudscape-design/components/i18n/messages/all.en.json';
import { applyTheme, Theme } from '@cloudscape-design/components/theming';

import { initAnalytics } from './analytics';
Expand Down Expand Up @@ -39,11 +41,14 @@ if (container) {

root.render(
<React.StrictMode>
<Provider store={store}>
<BusProvider>
<RouterProvider router={router} />
</BusProvider>
</Provider>
{/* Cloudscape's default labels, e.g. the property filter's Apply and Cancel, for any a page leaves out. */}
<I18nProvider locale="en" messages={[enMessages]}>
<Provider store={store}>
<BusProvider>
<RouterProvider router={router} />
</BusProvider>
</Provider>
</I18nProvider>
</React.StrictMode>,
);
}
22 changes: 18 additions & 4 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,14 +496,28 @@
},
"runs": {
"launch_button": "Launch",
"launch_page": {
"title": "Launch",
"select_project": "Select a project to see how to launch a run.",
"cli_title": "Run with the CLI",
"docs": "Docs",
"cli_install": "1. Install the CLI",
"cli_add_project": "2. Add this project",
"cli_define": "3. Define a configuration",
"cli_define_text": "Define a configuration in <code>.dstack.yml</code>.",
"cli_apply": "4. Apply the configuration",
"agents_title": "Run with agents",
"agents_description": "Let AI agents define configurations, run them, and manage <code>dstack</code> for you",
"template_title": "From a template",
"no_templates_text": "If you configure a repo with templates, you can run them from here."
},
"no_templates_alert": {
"title": "No templates configured",
"title": "No templates",
"description": "The selected project has no templates available for Launch.",
"action": "Settings"
},
"launch": {
"wizard": {
"title": "Launch",
"submit": "Apply",
"project": "Project",
"project_description": "Select a project",
Expand Down Expand Up @@ -573,9 +587,9 @@
"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": "No offers to display.",
"empty_message_text": "A backend may not be configured for this project.",
"nomatch_message_title": "No matches",
"nomatch_message_text": "We can't find a match."
"nomatch_message_text": "We can't find a match. Try to clear filter."
},

"models": {
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/pages/Offers/List/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { rangeToObject } from './helpers';
import { rangeToObject, sortGpusByPriceDesc } from './helpers';

const makeGpu = (name: string, price: { min: number; max: number }, availability: TAvailability[] = ['available']): IGpu => ({
name,
memory_mib: 81920,
vendor: 'nvidia',
availability,
spot: ['on-demand'],
count: { min: 1, max: 8 },
price,
});

describe('Offers helpers', () => {
test('sortGpusByPriceDesc puts available GPUs first, most expensive first', () => {
const gpus = [
makeGpu('A10', { min: 0.5, max: 4 }),
makeGpu('B200', { min: 6, max: 48 }, ['not_available']),
makeGpu('H100', { min: 2, max: 16 }),
makeGpu('H200', { min: 3, max: 24 }, ['no_quota', 'idle']),
makeGpu('L4', { min: 0.5, max: 2 }),
];

expect(sortGpusByPriceDesc(gpus).map(({ name }) => name)).toEqual(['H200', 'H100', 'A10', 'L4', 'B200']);
});

test('rangeToObject parses open and closed ranges', () => {
expect(rangeToObject('1..')).toEqual({ min: 1 });
expect(rangeToObject('..4')).toEqual({ max: 4 });
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/pages/Offers/List/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ export const getPropertyFilterOptions = (gpus: IGpu[]) => {
};
};

const AVAILABLE_STATUSES = new Set<TAvailability>(['unknown', 'available', 'idle']);

const isGpuAvailable = (gpu: IGpu) => gpu.availability.some((availability) => AVAILABLE_STATUSES.has(availability));

// Available first, as the server sorts, but the most expensive (best) GPUs before cheaper ones.
export const sortGpusByPriceDesc = (gpus: IGpu[]): IGpu[] =>
[...gpus].sort(
(a, b) =>
Number(isGpuAvailable(b)) - Number(isGpuAvailable(a)) || b.price.min - a.price.min || b.price.max - a.price.max,
);

export const getFleetFilterValue = (fleet: IFleet, selectedProjectName?: string) => {
if (selectedProjectName && fleet.project_name === selectedProjectName) {
return fleet.name;
Expand Down
42 changes: 24 additions & 18 deletions frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';

import { Button, ListEmptyMessage } from 'components';

import { ROUTES } from 'routes';

export const useEmptyMessages = ({
clearFilter,
isDisabledClearFilter,
projectNameSelected,
projectName,
groupBySelected,
hasCustomFilters,
}: {
clearFilter?: () => void;
isDisabledClearFilter?: boolean;
projectNameSelected?: boolean;
projectName?: string;
groupBySelected?: boolean;
hasCustomFilters?: boolean;
}) => {
const { t } = useTranslation();
const navigate = useNavigate();

const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => {
return (
<ListEmptyMessage title={t('offer.nomatch_message_title')} message={t('offer.nomatch_message_text')}>
<Button onClick={clearFilter}>{t('common.clearFilter')}</Button>
</ListEmptyMessage>
);
}, [clearFilter]);

const renderEmptyMessage = useCallback<() => React.ReactNode>(() => {
if (!projectNameSelected) {
if (!projectName) {
return (
<ListEmptyMessage
title={t('offer.empty_message_title_select_project')}
Expand All @@ -35,24 +47,18 @@ export const useEmptyMessages = ({
);
}

return (
<ListEmptyMessage title={t('offer.empty_message_title')} message={t('offer.empty_message_text')}>
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
{t('common.clearFilter')}
</Button>
</ListEmptyMessage>
);
}, [clearFilter, isDisabledClearFilter]);
if (hasCustomFilters) {
return renderNoMatchMessage();
}

const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => {
return (
<ListEmptyMessage title={t('offer.nomatch_message_title')} message={t('offer.nomatch_message_text')}>
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
{t('common.clearFilter')}
<ListEmptyMessage title={t('offer.empty_message_title')} message={t('offer.empty_message_text')}>
<Button onClick={() => navigate(ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(projectName))}>
{t('common.settings')}
</Button>
</ListEmptyMessage>
);
}, [clearFilter, isDisabledClearFilter]);
}, [projectName, groupBySelected, hasCustomFilters, renderNoMatchMessage]);

return { renderEmptyMessage, renderNoMatchMessage } as const;
};
Loading
Loading