From 606437efbd60f5846ac1ef1babd780a41d28724b Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 24 Dec 2025 14:28:31 +0300 Subject: [PATCH 01/12] No fleets notification #373 --- frontend/src/locale/en.json | 5 + frontend/src/pages/Fleets/List/index.tsx | 71 +++++++---- frontend/src/pages/Runs/List/index.tsx | 119 +++++++++++------- .../src/pages/Runs/List/styles.module.scss | 3 + frontend/src/services/fleet.ts | 2 +- 5 files changed, 134 insertions(+), 66 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 3281ba8f4c..cf0b3b340b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -563,6 +563,11 @@ }, "fleets": { + "no_alert": { + "title": "No fleets", + "description": "Please, create a fleet", + "button_title": "Create a fleet" + }, "fleet": "Fleet", "fleet_placeholder": "Filtering by fleet", "fleet_name": "Fleet name", diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index 3ac92310fb..f30704c164 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { ButtonProps } from '@cloudscape-design/components/button'; -import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; +import { Alert, Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { goToUrl } from 'libs'; import { ROUTES } from 'routes'; -import { useLazyGetFleetsQuery } from 'services/fleet'; +import { useGetFleetsQuery, useLazyGetFleetsQuery } from 'services/fleet'; import { useColumnsDefinitions, useEmptyMessages, useFilters } from './hooks'; import { useDeleteFleet } from './useDeleteFleet'; @@ -35,6 +37,8 @@ export const FleetList: React.FC = () => { isDisabledClearFilter, } = useFilters(); + const { data: fleetsData, isLoading: isLoadingFleets } = useGetFleetsQuery({ limit: 1 }); + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetFleetsQuery, args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, @@ -67,6 +71,13 @@ export const FleetList: React.FC = () => { deleteFleets([...selectedItems]).catch(console.log); }; + const noFleets = !isLoadingFleets && !fleetsData?.length; + + const onCreateAFleet: ButtonProps['onClick'] = (event) => { + event.preventDefault(); + goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); + }; + return ( { stickyHeader={true} selectionType="multi" header={ -
- - -
+ <> + {noFleets && ( +
+ + {t('fleets.no_alert.button_title')} + + } + > + {t('fleets.no_alert.description')} + +
+ )} + +
+ + +
+ } filter={
diff --git a/frontend/src/pages/Runs/List/index.tsx b/frontend/src/pages/Runs/List/index.tsx index e9715a76b3..da37751234 100644 --- a/frontend/src/pages/Runs/List/index.tsx +++ b/frontend/src/pages/Runs/List/index.tsx @@ -2,12 +2,15 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ButtonDropdownProps } from '@cloudscape-design/components'; +import { ButtonProps } from '@cloudscape-design/components/button'; -import { Button, ButtonDropdown, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; +import { Alert, Button, ButtonDropdown, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { goToUrl } from 'libs'; import { ROUTES } from 'routes'; +import { useGetFleetsQuery } from 'services/fleet'; import { useLazyGetRunsQuery } from 'services/run'; import { useRunListPreferences } from './Preferences/useRunListPreferences'; @@ -49,6 +52,8 @@ export const RunList: React.FC = () => { localStorePrefix: 'administration-run-list-page', }); + const { data: fleetsData, isLoading: isLoadingFleets } = useGetFleetsQuery({ limit: 1 }); + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetRunsQuery, args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE, job_submissions_limit: 1 }, @@ -117,6 +122,13 @@ export const RunList: React.FC = () => { } }; + const noFleets = !isLoadingFleets && !fleetsData?.length; + + const onCreateAFleet: ButtonProps['onClick'] = (event) => { + event.preventDefault(); + goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); + }; + return (
{ columnDisplay={preferences.contentDisplay} preferences={} header={ -
- + {noFleets && ( +
+ + {t('fleets.no_alert.button_title')} + + } > - {t('common.new')} - - - - - - - {/**/} - -
+ {t('fleets.no_alert.description')} + + + )} + +
+ + {t('common.new')} + + + + + + + {/**/} + +
+ } filter={
diff --git a/frontend/src/pages/Runs/List/styles.module.scss b/frontend/src/pages/Runs/List/styles.module.scss index 0b5efa7b66..2f42998451 100644 --- a/frontend/src/pages/Runs/List/styles.module.scss +++ b/frontend/src/pages/Runs/List/styles.module.scss @@ -1,3 +1,6 @@ +.alertBox { + margin-bottom: 16px; +} .selectFilters { display: flex; flex-wrap: wrap; diff --git a/frontend/src/services/fleet.ts b/frontend/src/services/fleet.ts index e74753b3ce..3405a18b8b 100644 --- a/frontend/src/services/fleet.ts +++ b/frontend/src/services/fleet.ts @@ -69,4 +69,4 @@ export const fleetApi = createApi({ }), }); -export const { useLazyGetFleetsQuery, useDeleteFleetMutation, useGetFleetDetailsQuery } = fleetApi; +export const { useGetFleetsQuery, useLazyGetFleetsQuery, useDeleteFleetMutation, useGetFleetDetailsQuery } = fleetApi; From 3d147404466351d38059b68fd11fcb1e84afff95 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 24 Dec 2025 14:32:36 +0300 Subject: [PATCH 02/12] Fixed styles https://github.com/dstackai/dstack-cloud/issues/373 --- frontend/src/pages/Fleets/List/styles.module.scss | 3 +++ frontend/src/pages/Runs/List/styles.module.scss | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Fleets/List/styles.module.scss b/frontend/src/pages/Fleets/List/styles.module.scss index 022678e83e..93adaed78c 100644 --- a/frontend/src/pages/Fleets/List/styles.module.scss +++ b/frontend/src/pages/Fleets/List/styles.module.scss @@ -1,3 +1,6 @@ +.alertBox { + margin-bottom: 12px; +} .filters { display: flex; flex-wrap: wrap; diff --git a/frontend/src/pages/Runs/List/styles.module.scss b/frontend/src/pages/Runs/List/styles.module.scss index 2f42998451..0122c12bc8 100644 --- a/frontend/src/pages/Runs/List/styles.module.scss +++ b/frontend/src/pages/Runs/List/styles.module.scss @@ -1,5 +1,5 @@ .alertBox { - margin-bottom: 16px; + margin-bottom: 12px; } .selectFilters { display: flex; From 04eff49d0ba710e88c770c00ca0f71bc97fb5ab3 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Wed, 24 Dec 2025 16:08:26 +0300 Subject: [PATCH 03/12] Fixed after review https://github.com/dstackai/dstack-cloud/issues/373 --- frontend/src/locale/en.json | 2 +- frontend/src/pages/Fleets/List/styles.module.scss | 9 +++++++++ frontend/src/pages/Runs/List/styles.module.scss | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index cf0b3b340b..cabaabb7bc 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -565,7 +565,7 @@ "fleets": { "no_alert": { "title": "No fleets", - "description": "Please, create a fleet", + "description": "The project has no fleets. Create one before submitting a run.", "button_title": "Create a fleet" }, "fleet": "Fleet", diff --git a/frontend/src/pages/Fleets/List/styles.module.scss b/frontend/src/pages/Fleets/List/styles.module.scss index 93adaed78c..1972454295 100644 --- a/frontend/src/pages/Fleets/List/styles.module.scss +++ b/frontend/src/pages/Fleets/List/styles.module.scss @@ -1,5 +1,14 @@ .alertBox { margin-bottom: 12px; + + :global { + & [class^="awsui_alert"] { + & [class^="awsui_action-slot"] { + display: flex; + align-items: center; + } + } + } } .filters { display: flex; diff --git a/frontend/src/pages/Runs/List/styles.module.scss b/frontend/src/pages/Runs/List/styles.module.scss index 0122c12bc8..5100d53f5f 100644 --- a/frontend/src/pages/Runs/List/styles.module.scss +++ b/frontend/src/pages/Runs/List/styles.module.scss @@ -1,5 +1,14 @@ .alertBox { margin-bottom: 12px; + + :global { + & [class^="awsui_alert"] { + & [class^="awsui_action-slot"] { + display: flex; + align-items: center; + } + } + } } .selectFilters { display: flex; From b7d38d6a23a1f5356f6eadc6fd56c38d0b71a2d9 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Thu, 25 Dec 2025 01:22:01 +0300 Subject: [PATCH 04/12] Fixed after review https://github.com/dstackai/dstack-cloud/issues/373 --- .../useCheckingForFleetsInProjectsOfMember.ts | 50 +++++++++++++++++++ frontend/src/pages/Fleets/List/index.tsx | 12 +++-- frontend/src/pages/Runs/List/index.tsx | 12 ++--- 3 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts new file mode 100644 index 0000000000..0bc772d5fe --- /dev/null +++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts @@ -0,0 +1,50 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useLazyGetFleetsQuery } from '../services/fleet'; +import { useGetProjectsQuery } from '../services/project'; + +type Args = { projectNames?: IProject['project_name'][] }; + +export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { + const [projectFleetMap, setProjectFleetMap] = useState>({}); + const { data: projectsData } = useGetProjectsQuery(undefined, { + skip: !!projectNames?.length, + }); + + const [getFleets] = useLazyGetFleetsQuery(); + + const projectNameForChecking = useMemo(() => { + if (projectNames) { + return projectNames; + } + + if (projectsData) { + return projectsData.map((project) => project.project_name); + } + + return []; + }, [projectNames, projectsData]); + + useEffect(() => { + const fetchFleets = async () => { + const map: Record = {}; + + await Promise.all( + projectNameForChecking.map((projectName) => + getFleets({ + limit: 1, + project_name: projectName, + }) + .unwrap() + .then((data) => (map[projectName] = Boolean(data.length))), + ), + ); + + setProjectFleetMap(map); + }; + + fetchFleets(); + }, [projectNameForChecking]); + + return projectFleetMap; +}; diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index f30704c164..5e52a09fbe 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -8,8 +8,9 @@ import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; import { goToUrl } from 'libs'; import { ROUTES } from 'routes'; -import { useGetFleetsQuery, useLazyGetFleetsQuery } from 'services/fleet'; +import { useLazyGetFleetsQuery } from 'services/fleet'; +import { useCheckingForFleetsInProjects } from '../../../hooks/useCheckingForFleetsInProjectsOfMember'; import { useColumnsDefinitions, useEmptyMessages, useFilters } from './hooks'; import { useDeleteFleet } from './useDeleteFleet'; @@ -37,7 +38,7 @@ export const FleetList: React.FC = () => { isDisabledClearFilter, } = useFilters(); - const { data: fleetsData, isLoading: isLoadingFleets } = useGetFleetsQuery({ limit: 1 }); + const projectHavingFleetMap = useCheckingForFleetsInProjects({}); const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetFleetsQuery, @@ -71,7 +72,7 @@ export const FleetList: React.FC = () => { deleteFleets([...selectedItems]).catch(console.log); }; - const noFleets = !isLoadingFleets && !fleetsData?.length; + const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); const onCreateAFleet: ButtonProps['onClick'] = (event) => { event.preventDefault(); @@ -90,7 +91,7 @@ export const FleetList: React.FC = () => { selectionType="multi" header={ <> - {noFleets && ( + {projectDontHasFleet && (
{ } > - {t('fleets.no_alert.description')} + Some of the projects (e.g. {projectDontHasFleet}) have no fleets. Create at least + one before submitting a run
)} diff --git a/frontend/src/pages/Runs/List/index.tsx b/frontend/src/pages/Runs/List/index.tsx index da37751234..b53f8b8d65 100644 --- a/frontend/src/pages/Runs/List/index.tsx +++ b/frontend/src/pages/Runs/List/index.tsx @@ -8,9 +8,9 @@ import { Alert, Button, ButtonDropdown, Header, Loader, PropertyFilter, SpaceBet import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { goToUrl } from 'libs'; import { ROUTES } from 'routes'; -import { useGetFleetsQuery } from 'services/fleet'; import { useLazyGetRunsQuery } from 'services/run'; import { useRunListPreferences } from './Preferences/useRunListPreferences'; @@ -52,7 +52,7 @@ export const RunList: React.FC = () => { localStorePrefix: 'administration-run-list-page', }); - const { data: fleetsData, isLoading: isLoadingFleets } = useGetFleetsQuery({ limit: 1 }); + const projectHavingFleetMap = useCheckingForFleetsInProjects({}); const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetRunsQuery, @@ -122,7 +122,7 @@ export const RunList: React.FC = () => { } }; - const noFleets = !isLoadingFleets && !fleetsData?.length; + const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); const onCreateAFleet: ButtonProps['onClick'] = (event) => { event.preventDefault(); @@ -143,7 +143,7 @@ export const RunList: React.FC = () => { preferences={} header={ <> - {noFleets && ( + {projectDontHasFleet && (
{ } > - {t('fleets.no_alert.description')} + Some of the projects (e.g. {projectDontHasFleet}) have no fleets. Create at least + one before submitting a run
)} @@ -164,7 +165,6 @@ export const RunList: React.FC = () => { actions={ Date: Thu, 25 Dec 2025 01:34:30 +0300 Subject: [PATCH 05/12] Added notification about fleets on project details page https://github.com/dstackai/dstack-cloud/issues/373 --- frontend/src/pages/Fleets/List/index.tsx | 6 ++-- .../pages/Project/Details/Settings/index.tsx | 32 ++++++++++++++++++- .../Details/Settings/styles.module.scss | 11 +++++++ frontend/src/pages/Runs/List/index.tsx | 4 +-- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index 5e52a09fbe..0966d89d1e 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -6,11 +6,11 @@ import { Alert, Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Tog import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { goToUrl } from 'libs'; import { ROUTES } from 'routes'; import { useLazyGetFleetsQuery } from 'services/fleet'; -import { useCheckingForFleetsInProjects } from '../../../hooks/useCheckingForFleetsInProjectsOfMember'; import { useColumnsDefinitions, useEmptyMessages, useFilters } from './hooks'; import { useDeleteFleet } from './useDeleteFleet'; @@ -102,8 +102,8 @@ export const FleetList: React.FC = () => { } > - Some of the projects (e.g. {projectDontHasFleet}) have no fleets. Create at least - one before submitting a run + The project {projectDontHasFleet} has no fleets. Create one before submitting a + run.
)} diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 2cd6b4915a..7a0fbfb8e6 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { debounce } from 'lodash'; import { ExpandableSection, Tabs } from '@cloudscape-design/components'; +import { ButtonProps } from '@cloudscape-design/components/button'; import Wizard from '@cloudscape-design/components/wizard'; import { + Alert, Box, Button, ButtonWithConfirmation, @@ -22,7 +24,7 @@ import { import { HotspotIds } from 'layouts/AppLayout/TutorialPanel/constants'; import { useBreadcrumbs, useNotifications } from 'hooks'; -import { riseRouterException } from 'libs'; +import { goToUrl, riseRouterException } from 'libs'; import { copyToClipboard } from 'libs'; import { ROUTES } from 'routes'; import { useGetProjectQuery, useUpdateProjectMembersMutation, useUpdateProjectMutation } from 'services/project'; @@ -35,6 +37,7 @@ import { useDeleteProject } from 'pages/Project/hooks/useDeleteProject'; import { ProjectMembers } from 'pages/Project/Members'; import { getProjectRoleByUserName } from 'pages/Project/utils'; +import { useCheckingForFleetsInProjects } from '../../../../hooks/useCheckingForFleetsInProjectsOfMember'; import { useBackendsTable } from '../../Backends/hooks'; import { BackendsTable } from '../../Backends/Table'; import { GatewaysTable } from '../../Gateways'; @@ -60,6 +63,10 @@ export const ProjectSettings: React.FC = () => { const { deleteProject, isDeleting } = useDeleteProject(); const { data: currentUser } = useGetUserDataQuery({}); + const projectNames = useMemo(() => [paramProjectName], [paramProjectName]); + + const projectHavingFleetMap = useCheckingForFleetsInProjects({ projectNames }); + const { data, isLoading, error } = useGetProjectQuery({ name: paramProjectName }); const { data: runsData } = useGetRunsQuery({ @@ -180,6 +187,13 @@ export const ProjectSettings: React.FC = () => { const [activeStepIndex, setActiveStepIndex] = React.useState(0); + const projectDontHasFleet = !projectHavingFleetMap?.[paramProjectName]; + + const onCreateAFleet: ButtonProps['onClick'] = (event) => { + event.preventDefault(); + goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); + }; + if (isLoadingPage) return ( @@ -191,6 +205,22 @@ export const ProjectSettings: React.FC = () => { <> {data && backendsData && gatewaysData && ( + {projectDontHasFleet && ( +
+ + {t('fleets.no_alert.button_title')} + + } + > + The project {paramProjectName} has no fleets. Create one before submitting a run. + +
+ )} + {isProjectMember && ( { } > - Some of the projects (e.g. {projectDontHasFleet}) have no fleets. Create at least - one before submitting a run + The project {projectDontHasFleet} has no fleets. Create one before submitting a + run. )} From 7365425c57af709801ab01fe76a7123d34245313 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Thu, 25 Dec 2025 12:20:09 +0300 Subject: [PATCH 06/12] Get only active fleets --- frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts index 0bc772d5fe..1028336070 100644 --- a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts +++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts @@ -34,6 +34,7 @@ export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { getFleets({ limit: 1, project_name: projectName, + only_active: true, }) .unwrap() .then((data) => (map[projectName] = Boolean(data.length))), From 74984b20c22440d737d1907bcca93d1d0e7fba8a Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 25 Dec 2025 14:16:58 +0100 Subject: [PATCH 07/12] [UX] Add an API that returns projects that lack active fleets --- .../_internal/server/routers/projects.py | 14 +- .../_internal/server/schemas/projects.py | 10 + .../_internal/server/services/fleets.py | 52 +- .../_internal/server/routers/test_projects.py | 498 ++++++++++++++++++ 4 files changed, 571 insertions(+), 3 deletions(-) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index d35b9535e8..2d75993252 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -23,7 +23,7 @@ ProjectManagerOrSelfLeave, ProjectMemberOrPublicAccess, ) -from dstack._internal.server.services import projects +from dstack._internal.server.services import fleets, projects from dstack._internal.server.utils.routers import ( CustomORJSONResponse, get_base_api_additional_responses, @@ -43,13 +43,23 @@ async def list_projects( user: UserModel = Depends(Authenticated()), ): """ - Returns all projects visible to user sorted by descending `created_at`. + Returns projects visible to the user, sorted by ascending `created_at`. + + If `only_no_fleets` is `True`: returns only projects where the user is a member + and that have no active fleets. + + Otherwise: returns all accessible projects (member projects for regular users, all non-deleted + projects for global admins, plus public projects if `include_not_joined` is `True`). `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. """ if body is None: # For backward compatibility body = ListProjectsRequest() + if body.only_no_fleets: + return CustomORJSONResponse( + await fleets.list_projects_with_no_active_fleets(session=session, user=user) + ) return CustomORJSONResponse( await projects.list_user_accessible_projects( session=session, user=user, include_not_joined=body.include_not_joined diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index ec05c1fb47..e78ed4cd2e 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -10,6 +10,16 @@ class ListProjectsRequest(CoreModel): include_not_joined: Annotated[ bool, Field(description="Include public projects where user is not a member") ] = True + only_no_fleets: Annotated[ + bool, + Field( + description=( + "If true, returns only projects where the user is a member and that have no active fleets. " + "Active fleets are those with `deleted == False`. " + "Projects with deleted fleets (but no active fleets) are included." + ) + ), + ] = False class CreateProjectRequest(CoreModel): diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py index 16901bdf1a..e347829fa4 100644 --- a/src/dstack/_internal/server/services/fleets.py +++ b/src/dstack/_internal/server/services/fleets.py @@ -6,7 +6,7 @@ from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.orm import aliased, joinedload, selectinload from dstack._internal.core.backends.base.backend import Backend from dstack._internal.core.backends.features import BACKENDS_WITH_CREATE_INSTANCE_SUPPORT @@ -40,6 +40,7 @@ Profile, SpotPolicy, ) +from dstack._internal.core.models.projects import Project from dstack._internal.core.models.resources import ResourcesSpec from dstack._internal.core.models.runs import JobProvisioningData, Requirements, get_policy_map from dstack._internal.core.models.users import GlobalRole @@ -50,6 +51,7 @@ FleetModel, InstanceModel, JobModel, + MemberModel, ProjectModel, UserModel, ) @@ -70,6 +72,7 @@ get_member, get_member_permissions, list_user_project_models, + project_model_to_project, ) from dstack._internal.server.services.resources import set_resources_defaults from dstack._internal.utils import random_names @@ -98,6 +101,53 @@ def switch_fleet_status( events.emit(session, msg, actor=actor, targets=[events.Target.from_model(fleet_model)]) +async def list_projects_with_no_active_fleets( + session: AsyncSession, + user: UserModel, +) -> List[Project]: + """ + Returns all projects where the user is a member that have no active fleets. + + Active fleets are those with `deleted == False`. Projects with only deleted fleets + (or no fleets) are included. Deleted projects are excluded. + + Applies to all users (both regular users and admins require membership). + """ + active_fleet_alias = aliased(FleetModel) + member_alias = aliased(MemberModel) + + query = ( + select(ProjectModel) + .join( + member_alias, + and_( + member_alias.project_id == ProjectModel.id, + member_alias.user_id == user.id, + ), + ) + .outerjoin( + active_fleet_alias, + and_( + active_fleet_alias.project_id == ProjectModel.id, + active_fleet_alias.deleted == False, + ), + ) + .where( + ProjectModel.deleted == False, + active_fleet_alias.id.is_(None), + ) + .order_by(ProjectModel.created_at) + ) + + res = await session.execute(query) + project_models = list(res.scalars().unique().all()) + + return [ + project_model_to_project(p, include_backends=False, include_members=False) + for p in project_models + ] + + async def list_fleets( session: AsyncSession, user: UserModel, diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 4b62ac416d..2e08fb5f2f 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -208,6 +208,504 @@ async def test_member_sees_both_public_and_private_projects( assert "public_project" in project_names assert "private_project" in project_names + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_returns_projects_without_active_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create project with no fleets + project_no_fleets = await create_project( + session=session, + owner=user, + name="project_no_fleets", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_no_fleets, user=user, project_role=ProjectRole.ADMIN + ) + + # Create project with active fleet + project_with_active_fleet = await create_project( + session=session, + owner=user, + name="project_with_active_fleet", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_active_fleet, + user=user, + project_role=ProjectRole.ADMIN, + ) + await create_fleet( + session=session, + project=project_with_active_fleet, + deleted=False, + ) + + # Create project with deleted fleet (should be included) + project_with_deleted_fleet = await create_project( + session=session, + owner=user, + name="project_with_deleted_fleet", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_deleted_fleet, + user=user, + project_role=ProjectRole.ADMIN, + ) + deleted_fleet = await create_fleet( + session=session, + project=project_with_deleted_fleet, + deleted=True, + ) + deleted_fleet.status = FleetStatus.TERMINATED + await session.commit() + + # Test with only_no_fleets=True + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return projects without active fleets + assert len(projects) == 2 + project_names = {p["project_name"] for p in projects} + assert "project_no_fleets" in project_names + assert "project_with_deleted_fleet" in project_names + assert "project_with_active_fleet" not in project_names + + # Test with only_no_fleets=False (default) + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": False}, + ) + assert response.status_code == 200 + projects = response.json() + + # Should return all projects + assert len(projects) == 3 + project_names = {p["project_name"] for p in projects} + assert "project_no_fleets" in project_names + assert "project_with_active_fleet" in project_names + assert "project_with_deleted_fleet" in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_with_multiple_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test project with multiple fleets - some active, some deleted""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create project with both active and deleted fleets + project_mixed = await create_project( + session=session, + owner=user, + name="project_mixed", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_mixed, user=user, project_role=ProjectRole.ADMIN + ) + # Add active fleet - should exclude project + await create_fleet( + session=session, + project=project_mixed, + deleted=False, + ) + # Add deleted fleet - should not affect exclusion + deleted_fleet = await create_fleet( + session=session, + project=project_mixed, + deleted=True, + ) + deleted_fleet.status = FleetStatus.TERMINATED + await session.commit() + + # Project should NOT be included because it has an active fleet + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + project_names = {p["project_name"] for p in projects} + assert "project_mixed" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_empty_result( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test when all projects have active fleets""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create projects, all with active fleets + for i in range(3): + project = await create_project( + session=session, + owner=user, + name=f"project_{i}", + created_at=datetime(2023, 1, 2, 3, 4 + i, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.ADMIN + ) + await create_fleet( + session=session, + project=project, + deleted=False, + ) + + # Should return empty list + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + assert len(projects) == 0 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_respects_user_permissions( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create regular user (not admin) + user = await create_user(session=session, global_role=GlobalRole.USER) + + # Create another user + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + + # Create project where user is a member (no fleets) + project_member = await create_project( + session=session, + owner=owner, + name="project_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_member, user=user, project_role=ProjectRole.USER + ) + await add_project_member( + session=session, project=project_member, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create public project where user is NOT a member (no fleets) + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + is_public=True, + ) + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create private project where user is NOT a member (should not see this) + private_project = await create_project( + session=session, + owner=owner, + name="private_project", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + is_public=False, + ) + await add_project_member( + session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Test with only_no_fleets=True + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return member projects without active fleets + # (public projects where user is not a member are no longer included) + assert len(projects) == 1 + project_names = {p["project_name"] for p in projects} + assert "project_member" in project_names + assert "public_project" not in project_names + assert "private_project" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_regular_user_filters_active_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that regular users correctly filter out projects with active fleets""" + # Create regular user (not admin) + user = await create_user(session=session, global_role=GlobalRole.USER) + + # Create another user + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + + # Create member project with no fleets (should be included) + project_member_no_fleet = await create_project( + session=session, + owner=owner, + name="project_member_no_fleet", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_member_no_fleet, + user=user, + project_role=ProjectRole.USER, + ) + + # Create member project with active fleet (should be excluded) + project_member_with_fleet = await create_project( + session=session, + owner=owner, + name="project_member_with_fleet", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_member_with_fleet, + user=user, + project_role=ProjectRole.USER, + ) + await create_fleet( + session=session, + project=project_member_with_fleet, + deleted=False, + ) + + # Create public project where user is a member with no fleets (should be included) + public_project_no_fleet = await create_project( + session=session, + owner=owner, + name="public_project_no_fleet", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + is_public=True, + ) + await add_project_member( + session=session, + project=public_project_no_fleet, + user=user, + project_role=ProjectRole.USER, + ) + + # Create public project where user is a member with active fleet (should be excluded) + public_project_with_fleet = await create_project( + session=session, + owner=owner, + name="public_project_with_fleet", + created_at=datetime(2023, 1, 2, 3, 7, tzinfo=timezone.utc), + is_public=True, + ) + await add_project_member( + session=session, + project=public_project_with_fleet, + user=user, + project_role=ProjectRole.USER, + ) + await create_fleet( + session=session, + project=public_project_with_fleet, + deleted=False, + ) + + # Test with only_no_fleets=True + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return member projects without active fleets + assert len(projects) == 2 + project_names = {p["project_name"] for p in projects} + assert "project_member_no_fleet" in project_names + assert "public_project_no_fleet" in project_names + assert "project_member_with_fleet" not in project_names + assert "public_project_with_fleet" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_filters_active_fleets_correctly( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that projects with active fleets are correctly filtered out""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create project with active fleet + project_with_active = await create_project( + session=session, + owner=user, + name="project_with_active", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_with_active, user=user, project_role=ProjectRole.ADMIN + ) + active_fleet = await create_fleet( + session=session, + project=project_with_active, + deleted=False, + ) + active_fleet.status = FleetStatus.ACTIVE + await session.commit() + + # Create project with terminated but not deleted fleet (still active) + project_with_terminated = await create_project( + session=session, + owner=user, + name="project_with_terminated", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_terminated, + user=user, + project_role=ProjectRole.ADMIN, + ) + terminated_fleet = await create_fleet( + session=session, + project=project_with_terminated, + deleted=False, + ) + terminated_fleet.status = FleetStatus.TERMINATED + await session.commit() + + # Both should be excluded + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + project_names = {p["project_name"] for p in projects} + assert "project_with_active" not in project_names + assert "project_with_terminated" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_sorted_by_created_at( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that results are sorted by created_at""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create projects in reverse order + project_3 = await create_project( + session=session, + owner=user, + name="project_3", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_3, user=user, project_role=ProjectRole.ADMIN + ) + + project_1 = await create_project( + session=session, + owner=user, + name="project_1", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_1, user=user, project_role=ProjectRole.ADMIN + ) + + project_2 = await create_project( + session=session, + owner=user, + name="project_2", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_2, user=user, project_role=ProjectRole.ADMIN + ) + + # Results should be sorted by created_at ascending + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + assert len(projects) == 3 + assert projects[0]["project_name"] == "project_1" + assert projects[1]["project_name"] == "project_2" + assert projects[2]["project_name"] == "project_3" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_admin_requires_membership( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that admins also require membership (unified behavior)""" + # Create admin user + admin = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create another user + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + + # Create project where admin is a member (no fleets) - should be included + project_with_membership = await create_project( + session=session, + owner=owner, + name="project_with_membership", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_membership, + user=admin, + project_role=ProjectRole.ADMIN, + ) + + # Create project where admin is NOT a member (no fleets) - should NOT be included + project_without_membership = await create_project( + session=session, + owner=owner, + name="project_without_membership", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_without_membership, + user=owner, + project_role=ProjectRole.ADMIN, + ) + + # Test with only_no_fleets=True + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(admin.token), + json={"only_no_fleets": True}, + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return project where admin is a member + assert len(projects) == 1 + project_names = {p["project_name"] for p in projects} + assert "project_with_membership" in project_names + assert "project_without_membership" not in project_names + class TestCreateProject: @pytest.mark.asyncio From 0cc421a5d2dd10de1efa294f384a6c68d46fe76e Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Fri, 26 Dec 2025 10:09:59 +0100 Subject: [PATCH 08/12] Moved the new logic from /api/rojects/lsit into a new API /api/projects/list_only_no_fleets --- .../_internal/server/routers/projects.py | 28 ++++++++---- .../_internal/server/schemas/projects.py | 10 ----- .../_internal/server/routers/test_projects.py | 43 +++++++++---------- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 2d75993252..b07b7b1c62 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -45,10 +45,7 @@ async def list_projects( """ Returns projects visible to the user, sorted by ascending `created_at`. - If `only_no_fleets` is `True`: returns only projects where the user is a member - and that have no active fleets. - - Otherwise: returns all accessible projects (member projects for regular users, all non-deleted + Returns all accessible projects (member projects for regular users, all non-deleted projects for global admins, plus public projects if `include_not_joined` is `True`). `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. @@ -56,10 +53,6 @@ async def list_projects( if body is None: # For backward compatibility body = ListProjectsRequest() - if body.only_no_fleets: - return CustomORJSONResponse( - await fleets.list_projects_with_no_active_fleets(session=session, user=user) - ) return CustomORJSONResponse( await projects.list_user_accessible_projects( session=session, user=user, include_not_joined=body.include_not_joined @@ -67,6 +60,25 @@ async def list_projects( ) +@router.post("/list_only_no_fleets", response_model=List[Project]) +async def list_only_no_fleets( + session: AsyncSession = Depends(get_session), + user: UserModel = Depends(Authenticated()), +): + """ + Returns only projects where the user is a member and that have no active fleets, + sorted by ascending `created_at`. + + Active fleets are those with `deleted == False`. Projects with deleted fleets + (but no active fleets) are included. + + `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. + """ + return CustomORJSONResponse( + await fleets.list_projects_with_no_active_fleets(session=session, user=user) + ) + + @router.post("/create", response_model=Project) async def create_project( body: CreateProjectRequest, diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index e78ed4cd2e..ec05c1fb47 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -10,16 +10,6 @@ class ListProjectsRequest(CoreModel): include_not_joined: Annotated[ bool, Field(description="Include public projects where user is not a member") ] = True - only_no_fleets: Annotated[ - bool, - Field( - description=( - "If true, returns only projects where the user is a member and that have no active fleets. " - "Active fleets are those with `deleted == False`. " - "Projects with deleted fleets (but no active fleets) are included." - ) - ), - ] = False class CreateProjectRequest(CoreModel): diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 2e08fb5f2f..5c9ef42ffb 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -34,6 +34,14 @@ async def test_returns_40x_if_not_authenticated(self, test_db, client: AsyncClie response = await client.post("/api/projects/list") assert response.status_code in [401, 403] + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_list_only_no_fleets_returns_40x_if_not_authenticated( + self, test_db, client: AsyncClient + ): + response = await client.post("/api/projects/list_only_no_fleets") + assert response.status_code in [401, 403] + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_returns_empty_list(self, test_db, session: AsyncSession, client: AsyncClient): @@ -266,11 +274,10 @@ async def test_only_no_fleets_returns_projects_without_active_fleets( deleted_fleet.status = FleetStatus.TERMINATED await session.commit() - # Test with only_no_fleets=True + # Test with list_only_no_fleets endpoint response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -282,11 +289,10 @@ async def test_only_no_fleets_returns_projects_without_active_fleets( assert "project_with_deleted_fleet" in project_names assert "project_with_active_fleet" not in project_names - # Test with only_no_fleets=False (default) + # Test with regular list endpoint (default) response = await client.post( "/api/projects/list", headers=get_auth_headers(user.token), - json={"only_no_fleets": False}, ) assert response.status_code == 200 projects = response.json() @@ -333,9 +339,8 @@ async def test_only_no_fleets_with_multiple_fleets( # Project should NOT be included because it has an active fleet response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -369,9 +374,8 @@ async def test_only_no_fleets_empty_result( # Should return empty list response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -426,11 +430,10 @@ async def test_only_no_fleets_respects_user_permissions( session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN ) - # Test with only_no_fleets=True + # Test with list_only_no_fleets endpoint response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -523,11 +526,10 @@ async def test_only_no_fleets_regular_user_filters_active_fleets( deleted=False, ) - # Test with only_no_fleets=True + # Test with list_only_no_fleets endpoint response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -589,9 +591,8 @@ async def test_only_no_fleets_filters_active_fleets_correctly( # Both should be excluded response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -640,9 +641,8 @@ async def test_only_no_fleets_sorted_by_created_at( # Results should be sorted by created_at ascending response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(user.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() @@ -691,11 +691,10 @@ async def test_only_no_fleets_admin_requires_membership( project_role=ProjectRole.ADMIN, ) - # Test with only_no_fleets=True + # Test with list_only_no_fleets endpoint response = await client.post( - "/api/projects/list", + "/api/projects/list_only_no_fleets", headers=get_auth_headers(admin.token), - json={"only_no_fleets": True}, ) assert response.status_code == 200 projects = response.json() From e2b6122930a9da5d537119ba20d8a013fecee12d Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 29 Dec 2025 19:07:33 +0300 Subject: [PATCH 09/12] implemented was using new api for checking fleets --- .../useCheckingForFleetsInProjectsOfMember.ts | 47 ++++++++----------- .../pages/Runs/CreateDevEnvironment/index.tsx | 34 +++++++++++++- .../CreateDevEnvironment/styles.module.scss | 13 +++++ frontend/src/services/project.ts | 5 +- frontend/src/types/project.d.ts | 5 ++ 5 files changed, 73 insertions(+), 31 deletions(-) diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts index 1028336070..0c89d4534a 100644 --- a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts +++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts @@ -1,17 +1,22 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; -import { useLazyGetFleetsQuery } from '../services/fleet'; import { useGetProjectsQuery } from '../services/project'; type Args = { projectNames?: IProject['project_name'][] }; export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { - const [projectFleetMap, setProjectFleetMap] = useState>({}); const { data: projectsData } = useGetProjectsQuery(undefined, { skip: !!projectNames?.length, }); - const [getFleets] = useLazyGetFleetsQuery(); + const { data: noFleetsProjectsData } = useGetProjectsQuery( + { + only_no_fleets: true, + }, + { + skip: !!projectNames?.length, + }, + ); const projectNameForChecking = useMemo(() => { if (projectNames) { @@ -25,27 +30,15 @@ export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { return []; }, [projectNames, projectsData]); - useEffect(() => { - const fetchFleets = async () => { - const map: Record = {}; - - await Promise.all( - projectNameForChecking.map((projectName) => - getFleets({ - limit: 1, - project_name: projectName, - only_active: true, - }) - .unwrap() - .then((data) => (map[projectName] = Boolean(data.length))), - ), - ); - - setProjectFleetMap(map); - }; - - fetchFleets(); - }, [projectNameForChecking]); - - return projectFleetMap; + const projectHavingFleetMap = useMemo>(() => { + const map: Record = {}; + + projectNameForChecking.forEach((projectName) => { + map[projectName] = !noFleetsProjectsData?.some((i) => i.project_name === projectName); + }); + + return map; + }, [projectNameForChecking, noFleetsProjectsData]); + + return projectHavingFleetMap; }; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx index 40e14c814d..d779d7fac8 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx @@ -5,13 +5,14 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import cn from 'classnames'; import * as yup from 'yup'; import { Box, Link, WizardProps } from '@cloudscape-design/components'; +import { ButtonProps } from '@cloudscape-design/components/button'; import { CardsProps } from '@cloudscape-design/components/cards'; -import type { TabsProps, ToggleProps } from 'components'; +import { Alert, Button, TabsProps, ToggleProps } from 'components'; import { Container, FormCodeEditor, FormField, FormInput, FormSelect, SpaceBetween, Tabs, Toggle, Wizard } from 'components'; import { useBreadcrumbs, useNotifications } from 'hooks'; -import { getServerError } from 'libs'; +import { getServerError, goToUrl } from 'libs'; import { ROUTES } from 'routes'; import { useApplyRunMutation } from 'services/run'; @@ -19,6 +20,7 @@ import { OfferList } from 'pages/Offers/List'; import { useGenerateYaml } from './hooks/useGenerateYaml'; import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; +import { useCheckingForFleetsInProjects } from '../../../hooks/useCheckingForFleetsInProjectsOfMember'; import { FORM_FIELD_NAMES } from './constants'; import { IRunEnvironmentFormKeys, IRunEnvironmentFormValues } from './types'; @@ -117,6 +119,9 @@ export const CreateDevEnvironment: React.FC = () => { const [getRunSpecFromYaml] = useGetRunSpecFromYaml({ projectName: selectedProject ?? '' }); + const projectHavingFleetMap = useCheckingForFleetsInProjects({ projectNames: selectedProject ? [selectedProject] : [] }); + const projectDontHasFleets = !!selectedProject && !projectHavingFleetMap[selectedProject]; + const [applyRun, { isLoading: isApplying }] = useApplyRunMutation(); const loading = isApplying; @@ -174,6 +179,10 @@ export const CreateDevEnvironment: React.FC = () => { const stepValidators = [validateOffer, validateSecondStep, validateConfig]; if (reason === 'next') { + if (projectDontHasFleets) { + window.scrollTo(0, 0); + } + stepValidators[activeStepIndex]?.().then((isValid) => { if (isValid) { setActiveStepIndex(requestedStepIndex); @@ -275,8 +284,29 @@ export const CreateDevEnvironment: React.FC = () => { setValue('config_yaml', yaml); }, [yaml]); + const onCreateAFleet: ButtonProps['onClick'] = (event) => { + event.preventDefault(); + goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); + }; + return (
+ {projectDontHasFleets && ( +
+ + {t('fleets.no_alert.button_title')} + + } + > + The project {selectedProject} has no fleets. Create one before submitting a run. + +
+ )} + ({ - getProjects: builder.query({ - query: () => { + getProjects: builder.query({ + query: (body) => { return { url: API.PROJECTS.LIST(), method: 'POST', + body, }; }, diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index c4a5cd0a55..cf24c84d03 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -7,6 +7,11 @@ declare type TCreateWizardProjectParams = { }; }; +declare type TGetProjectsParams = { + only_no_fleets?: boolean; + include_not_joined?: boolean; +}; + declare type TProjectBackend = { name: string; config: IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack; From dd927f945be976bbe8e26221a35690291fc28b65 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 29 Dec 2025 19:39:35 +0300 Subject: [PATCH 10/12] implemented was using new api for checking fleets --- frontend/src/api.ts | 1 + .../useCheckingForFleetsInProjectsOfMember.ts | 13 +++------ frontend/src/services/project.ts | 29 ++++++++++++++++--- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 262aa46b75..d58dbc7d38 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -63,6 +63,7 @@ export const API = { PROJECTS: { BASE: () => `${API.BASE()}/projects`, LIST: () => `${API.PROJECTS.BASE()}/list`, + LIST_ONLY_NO_FLEETS: () => `${API.PROJECTS.BASE()}/list_only_no_fleets`, CREATE: () => `${API.PROJECTS.BASE()}/create`, CREATE_WIZARD: () => `${API.PROJECTS.BASE()}/create_wizard`, DELETE: () => `${API.PROJECTS.BASE()}/delete`, diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts index 0c89d4534a..3157672113 100644 --- a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts +++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { useGetProjectsQuery } from '../services/project'; +import { useGetOnlyNoFleetsProjectsQuery, useGetProjectsQuery } from 'services/project'; type Args = { projectNames?: IProject['project_name'][] }; @@ -9,14 +9,9 @@ export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { skip: !!projectNames?.length, }); - const { data: noFleetsProjectsData } = useGetProjectsQuery( - { - only_no_fleets: true, - }, - { - skip: !!projectNames?.length, - }, - ); + const { data: noFleetsProjectsData } = useGetOnlyNoFleetsProjectsQuery(undefined, { + skip: !!projectNames?.length, + }); const projectNameForChecking = useMemo(() => { if (projectNames) { diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 8250dcb138..2f0a4bd6b5 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -20,15 +20,14 @@ export const projectApi = createApi({ prepareHeaders: fetchBaseQueryHeaders, }), - tagTypes: ['Projects', 'ProjectRepos', 'ProjectLogs', 'Backends'], + tagTypes: ['Projects', 'NoFleetsProject', 'ProjectRepos', 'ProjectLogs', 'Backends'], endpoints: (builder) => ({ - getProjects: builder.query({ - query: (body) => { + getProjects: builder.query({ + query: () => { return { url: API.PROJECTS.LIST(), method: 'POST', - body, }; }, @@ -41,6 +40,27 @@ export const projectApi = createApi({ : ['Projects'], }), + getOnlyNoFleetsProjects: builder.query({ + query: (body) => { + return { + url: API.PROJECTS.LIST_ONLY_NO_FLEETS(), + method: 'POST', + body, + }; + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformResponse: (response: any[]): IProject[] => response.map(transformProjectResponse), + + providesTags: (result) => + result + ? [ + ...result.map(({ project_name }) => ({ type: 'NoFleetsProject' as const, id: project_name })), + 'NoFleetsProject', + ] + : ['NoFleetsProject'], + }), + getProject: builder.query({ query: ({ name }) => { return { @@ -181,6 +201,7 @@ export const projectApi = createApi({ export const { useGetProjectsQuery, + useGetOnlyNoFleetsProjectsQuery, useLazyGetProjectsQuery, useGetProjectQuery, useCreateProjectMutation, From d03f5ef0fd2586dfde657df367d3b7db49273add Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Tue, 30 Dec 2025 00:09:05 +0300 Subject: [PATCH 11/12] implemented was using new api for checking fleets --- .../useCheckingForFleetsInProjectsOfMember.ts | 4 +- frontend/src/pages/Fleets/List/index.tsx | 32 +++--------- .../src/pages/Fleets/List/styles.module.scss | 11 +--- .../pages/Project/Details/Settings/index.tsx | 28 ++-------- .../Details/Settings/styles.module.scss | 11 ---- .../components/NoFleetProjectAlert/index.tsx | 52 +++++++++++++++++++ .../NoFleetProjectAlert/styles.module.scss | 10 ++++ .../pages/Runs/CreateDevEnvironment/index.tsx | 33 ++++-------- .../CreateDevEnvironment/styles.module.scss | 11 +--- frontend/src/pages/Runs/List/index.tsx | 32 +++--------- .../src/pages/Runs/List/styles.module.scss | 12 +---- 11 files changed, 96 insertions(+), 140 deletions(-) create mode 100644 frontend/src/pages/Project/components/NoFleetProjectAlert/index.tsx create mode 100644 frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts index 3157672113..d91b78a3b1 100644 --- a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts +++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts @@ -9,9 +9,7 @@ export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { skip: !!projectNames?.length, }); - const { data: noFleetsProjectsData } = useGetOnlyNoFleetsProjectsQuery(undefined, { - skip: !!projectNames?.length, - }); + const { data: noFleetsProjectsData } = useGetOnlyNoFleetsProjectsQuery(); const projectNameForChecking = useMemo(() => { if (projectNames) { diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index 0966d89d1e..e3e2dfc234 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -1,16 +1,16 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ButtonProps } from '@cloudscape-design/components/button'; -import { Alert, Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; +import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; -import { goToUrl } from 'libs'; import { ROUTES } from 'routes'; import { useLazyGetFleetsQuery } from 'services/fleet'; +import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; + import { useColumnsDefinitions, useEmptyMessages, useFilters } from './hooks'; import { useDeleteFleet } from './useDeleteFleet'; @@ -74,11 +74,6 @@ export const FleetList: React.FC = () => { const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); - const onCreateAFleet: ButtonProps['onClick'] = (event) => { - event.preventDefault(); - goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); - }; - return (
{ selectionType="multi" header={ <> - {projectDontHasFleet && ( -
- - {t('fleets.no_alert.button_title')} - - } - > - The project {projectDontHasFleet} has no fleets. Create one before submitting a - run. - -
- )} +
{ const projectDontHasFleet = !projectHavingFleetMap?.[paramProjectName]; - const onCreateAFleet: ButtonProps['onClick'] = (event) => { - event.preventDefault(); - goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); - }; - if (isLoadingPage) return ( @@ -205,21 +199,7 @@ export const ProjectSettings: React.FC = () => { <> {data && backendsData && gatewaysData && ( - {projectDontHasFleet && ( -
- - {t('fleets.no_alert.button_title')} - - } - > - The project {paramProjectName} has no fleets. Create one before submitting a run. - -
- )} + {isProjectMember && ( = ({ projectName, show, className, dismissible }) => { + const { t } = useTranslation(); + const [dontShowAgain, setDontShowAgain] = useLocalStorageState(`noFleetProjectAlert-${projectName}`, false); + + const onCreateAFleet: ButtonProps['onClick'] = (event) => { + event.preventDefault(); + goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); + }; + + const onDismiss: AlertProps['onDismiss'] = () => setDontShowAgain(true); + + if (!show || dontShowAgain) { + return null; + } + + return ( +
+ + {t('fleets.no_alert.button_title')} + + } + > + The project {projectName} has no fleets. Create one before submitting a run. + +
+ ); +}; diff --git a/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss b/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss new file mode 100644 index 0000000000..2afe2d13f4 --- /dev/null +++ b/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss @@ -0,0 +1,10 @@ +.alertBox { + :global { + & [class^="awsui_alert"] { + & [class^="awsui_action-slot"] { + display: flex; + align-items: center; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx index d779d7fac8..278bc5b3c5 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx @@ -5,22 +5,22 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import cn from 'classnames'; import * as yup from 'yup'; import { Box, Link, WizardProps } from '@cloudscape-design/components'; -import { ButtonProps } from '@cloudscape-design/components/button'; import { CardsProps } from '@cloudscape-design/components/cards'; -import { Alert, Button, TabsProps, ToggleProps } from 'components'; +import { TabsProps, ToggleProps } from 'components'; import { Container, FormCodeEditor, FormField, FormInput, FormSelect, SpaceBetween, Tabs, Toggle, Wizard } from 'components'; import { useBreadcrumbs, useNotifications } from 'hooks'; -import { getServerError, goToUrl } from 'libs'; +import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; +import { getServerError } from 'libs'; import { ROUTES } from 'routes'; import { useApplyRunMutation } from 'services/run'; import { OfferList } from 'pages/Offers/List'; +import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; import { useGenerateYaml } from './hooks/useGenerateYaml'; import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; -import { useCheckingForFleetsInProjects } from '../../../hooks/useCheckingForFleetsInProjectsOfMember'; import { FORM_FIELD_NAMES } from './constants'; import { IRunEnvironmentFormKeys, IRunEnvironmentFormValues } from './types'; @@ -284,28 +284,13 @@ export const CreateDevEnvironment: React.FC = () => { setValue('config_yaml', yaml); }, [yaml]); - const onCreateAFleet: ButtonProps['onClick'] = (event) => { - event.preventDefault(); - goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); - }; - return ( - {projectDontHasFleets && ( -
- - {t('fleets.no_alert.button_title')} - - } - > - The project {selectedProject} has no fleets. Create one before submitting a run. - -
- )} + { const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); - const onCreateAFleet: ButtonProps['onClick'] = (event) => { - event.preventDefault(); - goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); - }; - return (
{ preferences={} header={ <> - {projectDontHasFleet && ( -
- - {t('fleets.no_alert.button_title')} - - } - > - The project {projectDontHasFleet} has no fleets. Create one before submitting a - run. - -
- )} +
Date: Tue, 30 Dec 2025 00:16:51 +0300 Subject: [PATCH 12/12] implemented was using new api for checking fleets --- .../Project/components/NoFleetProjectAlert/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss b/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss index 2afe2d13f4..c49d1793fb 100644 --- a/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss +++ b/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss @@ -7,4 +7,4 @@ } } } -} \ No newline at end of file +}