From 6feab59b55e595ccc8e4580f870c21e05658f203 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Tue, 13 Oct 2026 17:11:24 +0200 Subject: [PATCH] Add project quotas to the UI --- frontend/src/api.ts | 2 + .../pages/Project/Details/Quotas/index.tsx | 51 +++++++++++ frontend/src/pages/Project/Details/index.tsx | 28 ++++-- .../Project/components/QuotasTable/index.tsx | 85 +++++++++++++++++++ frontend/src/pages/Project/index.tsx | 1 + frontend/src/router.tsx | 14 ++- frontend/src/routes.ts | 5 ++ frontend/src/services/backendQuota.ts | 17 ++++ frontend/src/types/backend.d.ts | 5 ++ 9 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/Project/Details/Quotas/index.tsx create mode 100644 frontend/src/pages/Project/components/QuotasTable/index.tsx create mode 100644 frontend/src/services/backendQuota.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bd9fc32330..5092cc9f9c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -137,6 +137,8 @@ export const API = { PROJECT_BACKENDS: { BASE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/backends`, + QUOTAS: (projectName: IProject['project_name'], backendName: string) => + `${API.BASE()}/project/${projectName}/backend/${backendName}/quotas/list`, LIST: (projectName: IProject['project_name']) => `${API.PROJECT_BACKENDS.BASE(projectName)}/list`, CREATE: (projectName: IProject['project_name']) => `${API.PROJECT_BACKENDS.BASE(projectName)}/create`, UPDATE: (projectName: IProject['project_name']) => `${API.PROJECT_BACKENDS.BASE(projectName)}/update`, diff --git a/frontend/src/pages/Project/Details/Quotas/index.tsx b/frontend/src/pages/Project/Details/Quotas/index.tsx new file mode 100644 index 0000000000..73c901d320 --- /dev/null +++ b/frontend/src/pages/Project/Details/Quotas/index.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { product } from 'product'; + +import { Alert, Container, Loader } from 'components'; + +import { useBreadcrumbs } from 'hooks'; +import { ROUTES } from 'routes'; +import { useGetProjectQuery } from 'services/project'; + +import { QuotasTable } from '../../components/QuotasTable'; +import { useCheckAvailableProjectPermission } from '../../hooks/useCheckAvailableProjectPermission'; + +export const ProjectQuotas: React.FC = () => { + const { t } = useTranslation(); + const { projectName = '' } = useParams(); + const { currentData: project, error } = useGetProjectQuery( + { name: projectName }, + { skip: !product.hasBilling, refetchOnMountOrArgChange: false }, + ); + const { isProjectAdmin } = useCheckAvailableProjectPermission(); + + useBreadcrumbs([ + { text: t('navigation.project_other'), href: ROUTES.PROJECT.LIST }, + { text: projectName, href: ROUTES.PROJECT.DETAILS.FORMAT(projectName) }, + { text: 'Quotas', href: ROUTES.PROJECT.DETAILS.QUOTAS.FORMAT(projectName) }, + ]); + + if (!product.hasBilling) { + return ; + } + + if (error && !project) { + return Unable to load project.; + } + + if (!project) { + return ( + + + + ); + } + + if (!isProjectAdmin(project)) { + return ; + } + + return ; +}; diff --git a/frontend/src/pages/Project/Details/index.tsx b/frontend/src/pages/Project/Details/index.tsx index 7905ed492e..3f676d84f3 100644 --- a/frontend/src/pages/Project/Details/index.tsx +++ b/frontend/src/pages/Project/Details/index.tsx @@ -6,6 +6,9 @@ import { product } from 'product'; import { ContentLayout, DetailsHeader, Tabs } from 'components'; import { ROUTES } from 'routes'; +import { projectApi } from 'services/project'; + +import { useCheckAvailableProjectPermission } from '../hooks/useCheckAvailableProjectPermission'; import styles from './styles.module.scss'; @@ -15,7 +18,15 @@ export const ProjectDetails: React.FC = () => { const { t } = useTranslation(); const matchSettings = useMatch(ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(paramProjectName)); + const matchQuotas = useMatch(ROUTES.PROJECT.DETAILS.QUOTAS.FORMAT(paramProjectName)); const matchEvents = useMatch(ROUTES.PROJECT.DETAILS.EVENTS.FORMAT(paramProjectName)); + const { currentData: project } = projectApi.endpoints.getProject.useQueryState({ name: paramProjectName }); + const { isProjectAdmin } = useCheckAvailableProjectPermission(); + const showTabs = useMemo(() => { + return Boolean(matchSettings) || Boolean(matchQuotas) || Boolean(matchEvents); + }, [matchSettings, matchQuotas, matchEvents]); + // TODO: Gate Quotas on dstack backend presence once the project response exposes it. + const hasQuotas = product.hasBilling && project && isProjectAdmin(project); const tabs = [ { @@ -28,20 +39,27 @@ export const ProjectDetails: React.FC = () => { id: 'events', href: ROUTES.PROJECT.DETAILS.EVENTS.FORMAT(paramProjectName), }, + hasQuotas && { + label: 'Quotas', + id: 'quotas', + href: ROUTES.PROJECT.DETAILS.QUOTAS.FORMAT(paramProjectName), + }, ].filter(Boolean) as { label: string; id: string; href: string; }[]; - const showTabs = useMemo(() => { - return Boolean(matchSettings) || Boolean(matchEvents); - }, [matchSettings, matchEvents]); - return (
}> - {showTabs && } + {showTabs && ( + + )} diff --git a/frontend/src/pages/Project/components/QuotasTable/index.tsx b/frontend/src/pages/Project/components/QuotasTable/index.tsx new file mode 100644 index 0000000000..360511e021 --- /dev/null +++ b/frontend/src/pages/Project/components/QuotasTable/index.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { product } from 'product'; + +import { Alert, Button, Header, InfoLink, Spinner, Table } from 'components'; + +import { DISCORD_URL } from 'consts'; +import { useHelpPanel } from 'hooks'; +import { useGetBackendQuotasQuery } from 'services/backendQuota'; + +const QUOTA_INFO = { + header:

Quotas

, + body: ( + <> +

+ These quotas apply per project to the backend with type dstack and are shared across its GPU + marketplace providers using {product.isSky ? 'Sky' : 'dstack Factory'} billing. +

+

+ Each instance counts toward the quota by the CPU count shown in its offer. This count varies by instance type + and backend. +

+

Backends connected to your own cloud accounts use your cloud provider's quotas.

+

+ To request an increase, ask in #help on{' '} + + Discord + + . +

+ + ), +}; + +export const QuotasTable: React.FC<{ projectName: string }> = ({ projectName }) => { + const [openHelpPanel] = useHelpPanel(); + const { currentData: quotas, isFetching, error } = useGetBackendQuotasQuery({ projectName, backendName: 'dstack' }); + const items = [ + { name: 'cpu', value: quotas ? (quotas.find((quota) => quota.name === 'cpu')?.value ?? null) : undefined }, + ...(quotas?.filter((quota) => quota.name !== 'cpu') ?? []), + ]; + + return ( +
+ openHelpPanel(QUOTA_INFO)} />} + actions={ + + } + > + Quotas + + } + columnDefinitions={[ + { id: 'type', header: 'Type', cell: (item) => (item.name === 'cpu' ? 'vCPUs' : item.name) }, + { + id: 'value', + header: 'Value', + cell: (item) => + isFetching && !quotas ? ( + + ) : item.value === undefined ? ( + '—' + ) : item.value === null ? ( + 'No limit' + ) : ( + item.value.toLocaleString() + ), + }, + ]} + items={items} + footer={error ? Unable to load quotas. : undefined} + /> + + ); +}; diff --git a/frontend/src/pages/Project/index.tsx b/frontend/src/pages/Project/index.tsx index 503d4c2228..df85a2289e 100644 --- a/frontend/src/pages/Project/index.tsx +++ b/frontend/src/pages/Project/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; export { ProjectList } from './List'; export { ProjectDetails } from './Details'; export { ProjectSettings } from './Details/Settings'; +export { ProjectQuotas } from './Details/Quotas'; export { Events as ProjectEvents } from './Details/Events'; export { ProjectAdd } from './Add'; export { CreateProjectWizard } from './CreateWizard'; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index d0a66b57b4..fd9ed3bf27 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -24,7 +24,15 @@ import { InstanceInspect } from 'pages/Instances/Details/Inspect'; import { InstanceDetails } from 'pages/Instances/Details/InstanceDetails'; import { ModelsList } from 'pages/Models'; import { ModelDetails } from 'pages/Models/Details'; -import { CreateProjectWizard, ProjectAdd, ProjectDetails, ProjectEvents, ProjectList, ProjectSettings } from 'pages/Project'; +import { + CreateProjectWizard, + ProjectAdd, + ProjectDetails, + ProjectEvents, + ProjectList, + ProjectQuotas, + ProjectSettings, +} from 'pages/Project'; import { BackendAdd, BackendEdit } from 'pages/Project/Backends'; import { AddGateway, EditGateway } from 'pages/Project/Gateways'; import { @@ -121,6 +129,10 @@ export const router = createBrowserRouter([ index: true, element: , }, + product.hasBilling && { + path: ROUTES.PROJECT.DETAILS.QUOTAS.TEMPLATE, + element: , + }, product.hasEvents && { path: ROUTES.PROJECT.DETAILS.EVENTS.TEMPLATE, element: , diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 7cfd9bb92a..7e97253d1c 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -23,6 +23,11 @@ export const ROUTES = { FORMAT: (projectName: string) => buildRoute(ROUTES.PROJECT.DETAILS.SETTINGS.TEMPLATE, { projectName }), }, + QUOTAS: { + TEMPLATE: `/projects/:projectName/quotas`, + FORMAT: (projectName: string) => buildRoute(ROUTES.PROJECT.DETAILS.QUOTAS.TEMPLATE, { projectName }), + }, + EVENTS: { TEMPLATE: `/projects/:projectName/events`, FORMAT: (projectName: string) => buildRoute(ROUTES.PROJECT.DETAILS.EVENTS.TEMPLATE, { projectName }), diff --git a/frontend/src/services/backendQuota.ts b/frontend/src/services/backendQuota.ts new file mode 100644 index 0000000000..4b6339d4c5 --- /dev/null +++ b/frontend/src/services/backendQuota.ts @@ -0,0 +1,17 @@ +import { API } from 'api'; + +import { projectApi } from './project'; + +export const backendQuotaApi = projectApi.injectEndpoints({ + endpoints: (builder) => ({ + getBackendQuotas: builder.query({ + query: ({ projectName, backendName }) => ({ + url: API.PROJECT_BACKENDS.QUOTAS(projectName, backendName), + method: 'POST', + }), + providesTags: (result, error, { projectName }) => [{ type: 'Backends', id: `${projectName}/quota` }], + }), + }), +}); + +export const { useGetBackendQuotasQuery } = backendQuotaApi; diff --git a/frontend/src/types/backend.d.ts b/frontend/src/types/backend.d.ts index d42a26ba17..e18b89b006 100644 --- a/frontend/src/types/backend.d.ts +++ b/frontend/src/types/backend.d.ts @@ -47,3 +47,8 @@ declare interface IProjectBackend { name: string; config: TBackendConfig; } + +declare interface IBackendQuota { + name: string; + value: number; +}