Skip to content

Commit 6adffca

Browse files
[UX] Add an API that returns projects that lack active fleets (#3425)
No fleets notification dstackai/dstack-cloud#373
1 parent 4893cb3 commit 6adffca

File tree

18 files changed

+836
-69
lines changed

18 files changed

+836
-69
lines changed

frontend/src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const API = {
6363
PROJECTS: {
6464
BASE: () => `${API.BASE()}/projects`,
6565
LIST: () => `${API.PROJECTS.BASE()}/list`,
66+
LIST_ONLY_NO_FLEETS: () => `${API.PROJECTS.BASE()}/list_only_no_fleets`,
6667
CREATE: () => `${API.PROJECTS.BASE()}/create`,
6768
CREATE_WIZARD: () => `${API.PROJECTS.BASE()}/create_wizard`,
6869
DELETE: () => `${API.PROJECTS.BASE()}/delete`,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useMemo } from 'react';
2+
3+
import { useGetOnlyNoFleetsProjectsQuery, useGetProjectsQuery } from 'services/project';
4+
5+
type Args = { projectNames?: IProject['project_name'][] };
6+
7+
export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => {
8+
const { data: projectsData } = useGetProjectsQuery(undefined, {
9+
skip: !!projectNames?.length,
10+
});
11+
12+
const { data: noFleetsProjectsData } = useGetOnlyNoFleetsProjectsQuery();
13+
14+
const projectNameForChecking = useMemo<IProject['project_name'][]>(() => {
15+
if (projectNames) {
16+
return projectNames;
17+
}
18+
19+
if (projectsData) {
20+
return projectsData.map((project) => project.project_name);
21+
}
22+
23+
return [];
24+
}, [projectNames, projectsData]);
25+
26+
const projectHavingFleetMap = useMemo<Record<IProject['project_name'], boolean>>(() => {
27+
const map: Record<IProject['project_name'], boolean> = {};
28+
29+
projectNameForChecking.forEach((projectName) => {
30+
map[projectName] = !noFleetsProjectsData?.some((i) => i.project_name === projectName);
31+
});
32+
33+
return map;
34+
}, [projectNameForChecking, noFleetsProjectsData]);
35+
36+
return projectHavingFleetMap;
37+
};

frontend/src/locale/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,11 @@
564564
},
565565

566566
"fleets": {
567+
"no_alert": {
568+
"title": "No fleets",
569+
"description": "The project has no fleets. Create one before submitting a run.",
570+
"button_title": "Create a fleet"
571+
},
567572
"fleet": "Fleet",
568573
"fleet_placeholder": "Filtering by fleet",
569574
"fleet_name": "Fleet name",

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

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } f
55

66
import { DEFAULT_TABLE_PAGE_SIZE } from 'consts';
77
import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks';
8+
import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember';
89
import { ROUTES } from 'routes';
910
import { useLazyGetFleetsQuery } from 'services/fleet';
1011

12+
import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert';
13+
1114
import { useColumnsDefinitions, useEmptyMessages, useFilters } from './hooks';
1215
import { useDeleteFleet } from './useDeleteFleet';
1316

@@ -35,6 +38,8 @@ export const FleetList: React.FC = () => {
3538
isDisabledClearFilter,
3639
} = useFilters();
3740

41+
const projectHavingFleetMap = useCheckingForFleetsInProjects({});
42+
3843
const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll<IFleet, TFleetListRequestParams>({
3944
useLazyQuery: useLazyGetFleetsQuery,
4045
args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE },
@@ -67,6 +72,8 @@ export const FleetList: React.FC = () => {
6772
deleteFleets([...selectedItems]).catch(console.log);
6873
};
6974

75+
const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]);
76+
7077
return (
7178
<Table
7279
{...collectionProps}
@@ -78,25 +85,33 @@ export const FleetList: React.FC = () => {
7885
stickyHeader={true}
7986
selectionType="multi"
8087
header={
81-
<Header
82-
variant="awsui-h1-sticky"
83-
actions={
84-
<SpaceBetween size="xs" direction="horizontal">
85-
<Button formAction="none" onClick={deleteClickHandle} disabled={isDisabledDeleteButton}>
86-
{t('common.delete')}
87-
</Button>
88-
89-
<Button
90-
iconName="refresh"
91-
disabled={isLoading}
92-
ariaLabel={t('common.refresh')}
93-
onClick={refreshList}
94-
/>
95-
</SpaceBetween>
96-
}
97-
>
98-
{t('navigation.fleets')}
99-
</Header>
88+
<>
89+
<NoFleetProjectAlert
90+
className={styles.noFleetAlert}
91+
projectName={projectDontHasFleet ?? ''}
92+
show={!!projectDontHasFleet}
93+
/>
94+
95+
<Header
96+
variant="awsui-h1-sticky"
97+
actions={
98+
<SpaceBetween size="xs" direction="horizontal">
99+
<Button formAction="none" onClick={deleteClickHandle} disabled={isDisabledDeleteButton}>
100+
{t('common.delete')}
101+
</Button>
102+
103+
<Button
104+
iconName="refresh"
105+
disabled={isLoading}
106+
ariaLabel={t('common.refresh')}
107+
onClick={refreshList}
108+
/>
109+
</SpaceBetween>
110+
}
111+
>
112+
{t('navigation.fleets')}
113+
</Header>
114+
</>
100115
}
101116
filter={
102117
<div className={styles.filters}>

frontend/src/pages/Fleets/List/styles.module.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
.noFleetAlert {
2+
margin-bottom: 12px;
3+
}
14
.filters {
25
display: flex;
36
flex-wrap: wrap;

frontend/src/pages/Project/Details/Settings/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { HotspotIds } from 'layouts/AppLayout/TutorialPanel/constants';
2323

2424
import { useBreadcrumbs, useNotifications } from 'hooks';
25+
import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember';
2526
import { riseRouterException } from 'libs';
2627
import { copyToClipboard } from 'libs';
2728
import { ROUTES } from 'routes';
@@ -37,6 +38,7 @@ import { getProjectRoleByUserName } from 'pages/Project/utils';
3738

3839
import { useBackendsTable } from '../../Backends/hooks';
3940
import { BackendsTable } from '../../Backends/Table';
41+
import { NoFleetProjectAlert } from '../../components/NoFleetProjectAlert';
4042
import { GatewaysTable } from '../../Gateways';
4143
import { useGatewaysTable } from '../../Gateways/hooks';
4244
import { ProjectSecrets } from '../../Secrets';
@@ -60,6 +62,10 @@ export const ProjectSettings: React.FC = () => {
6062
const { deleteProject, isDeleting } = useDeleteProject();
6163
const { data: currentUser } = useGetUserDataQuery({});
6264

65+
const projectNames = useMemo(() => [paramProjectName], [paramProjectName]);
66+
67+
const projectHavingFleetMap = useCheckingForFleetsInProjects({ projectNames });
68+
6369
const { data, isLoading, error } = useGetProjectQuery({ name: paramProjectName });
6470

6571
const { data: runsData } = useGetRunsQuery({
@@ -180,6 +186,8 @@ export const ProjectSettings: React.FC = () => {
180186

181187
const [activeStepIndex, setActiveStepIndex] = React.useState(0);
182188

189+
const projectDontHasFleet = !projectHavingFleetMap?.[paramProjectName];
190+
183191
if (isLoadingPage)
184192
return (
185193
<Container>
@@ -191,6 +199,8 @@ export const ProjectSettings: React.FC = () => {
191199
<>
192200
{data && backendsData && gatewaysData && (
193201
<SpaceBetween size="l">
202+
<NoFleetProjectAlert projectName={paramProjectName} show={projectDontHasFleet} dismissible />
203+
194204
{isProjectMember && (
195205
<ExpandableSection
196206
variant="container"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import cn from 'classnames';
4+
5+
import type { ButtonProps } from 'components';
6+
import { Alert, AlertProps, Button } from 'components';
7+
8+
import { useLocalStorageState } from 'hooks/useLocalStorageState';
9+
import { goToUrl } from 'libs';
10+
11+
import styles from './styles.module.scss';
12+
13+
type NoFleetProjectAlertProps = {
14+
show?: boolean;
15+
projectName: string;
16+
className?: string;
17+
dismissible?: boolean;
18+
};
19+
20+
export const NoFleetProjectAlert: React.FC<NoFleetProjectAlertProps> = ({ projectName, show, className, dismissible }) => {
21+
const { t } = useTranslation();
22+
const [dontShowAgain, setDontShowAgain] = useLocalStorageState(`noFleetProjectAlert-${projectName}`, false);
23+
24+
const onCreateAFleet: ButtonProps['onClick'] = (event) => {
25+
event.preventDefault();
26+
goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true);
27+
};
28+
29+
const onDismiss: AlertProps['onDismiss'] = () => setDontShowAgain(true);
30+
31+
if (!show || dontShowAgain) {
32+
return null;
33+
}
34+
35+
return (
36+
<div className={cn(styles.alertBox, className)}>
37+
<Alert
38+
header={t('fleets.no_alert.title')}
39+
type="info"
40+
dismissible={dismissible}
41+
onDismiss={onDismiss}
42+
action={
43+
<Button iconName="external" formAction="none" onClick={onCreateAFleet}>
44+
{t('fleets.no_alert.button_title')}
45+
</Button>
46+
}
47+
>
48+
The project <code>{projectName}</code> has no fleets. Create one before submitting a run.
49+
</Alert>
50+
</div>
51+
);
52+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.alertBox {
2+
:global {
3+
& [class^="awsui_alert"] {
4+
& [class^="awsui_action-slot"] {
5+
display: flex;
6+
align-items: center;
7+
}
8+
}
9+
}
10+
}

frontend/src/pages/Runs/CreateDevEnvironment/index.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import * as yup from 'yup';
77
import { Box, Link, WizardProps } from '@cloudscape-design/components';
88
import { CardsProps } from '@cloudscape-design/components/cards';
99

10-
import type { TabsProps, ToggleProps } from 'components';
10+
import { TabsProps, ToggleProps } from 'components';
1111
import { Container, FormCodeEditor, FormField, FormInput, FormSelect, SpaceBetween, Tabs, Toggle, Wizard } from 'components';
1212

1313
import { useBreadcrumbs, useNotifications } from 'hooks';
14+
import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember';
1415
import { getServerError } from 'libs';
1516
import { ROUTES } from 'routes';
1617
import { useApplyRunMutation } from 'services/run';
1718

1819
import { OfferList } from 'pages/Offers/List';
20+
import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert';
1921

2022
import { useGenerateYaml } from './hooks/useGenerateYaml';
2123
import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml';
@@ -117,6 +119,9 @@ export const CreateDevEnvironment: React.FC = () => {
117119

118120
const [getRunSpecFromYaml] = useGetRunSpecFromYaml({ projectName: selectedProject ?? '' });
119121

122+
const projectHavingFleetMap = useCheckingForFleetsInProjects({ projectNames: selectedProject ? [selectedProject] : [] });
123+
const projectDontHasFleets = !!selectedProject && !projectHavingFleetMap[selectedProject];
124+
120125
const [applyRun, { isLoading: isApplying }] = useApplyRunMutation();
121126

122127
const loading = isApplying;
@@ -174,6 +179,10 @@ export const CreateDevEnvironment: React.FC = () => {
174179
const stepValidators = [validateOffer, validateSecondStep, validateConfig];
175180

176181
if (reason === 'next') {
182+
if (projectDontHasFleets) {
183+
window.scrollTo(0, 0);
184+
}
185+
177186
stepValidators[activeStepIndex]?.().then((isValid) => {
178187
if (isValid) {
179188
setActiveStepIndex(requestedStepIndex);
@@ -277,6 +286,12 @@ export const CreateDevEnvironment: React.FC = () => {
277286

278287
return (
279288
<form className={cn({ [styles.wizardForm]: activeStepIndex === 0 })} onSubmit={handleSubmit(onSubmit)}>
289+
<NoFleetProjectAlert
290+
className={styles.noFleetAlert}
291+
projectName={selectedProject ?? ''}
292+
show={projectDontHasFleets}
293+
/>
294+
280295
<Wizard
281296
activeStepIndex={activeStepIndex}
282297
onNavigate={onNavigateHandler}

frontend/src/pages/Runs/CreateDevEnvironment/styles.module.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
@use '@cloudscape-design/design-tokens/index' as awsui;
22

3+
.noFleetAlert {
4+
margin: 12px 0 0;
5+
}
6+
37
.wizardForm {
48
& [class^="awsui_wizard"] {
59
& [class^="awsui_footer"] {

0 commit comments

Comments
 (0)