}>
- {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={
+
+ Increase
+
+ }
+ >
+ 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;
+}