Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/pages/Project/Details/Quotas/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to={ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(projectName)} replace />;
}

if (error && !project) {
return <Alert type="error">Unable to load project.</Alert>;
}

if (!project) {
return (
<Container>
<Loader />
</Container>
);
}

if (!isProjectAdmin(project)) {
return <Navigate to={ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(projectName)} replace />;
}

return <QuotasTable key={projectName} projectName={projectName} />;
};
28 changes: 23 additions & 5 deletions frontend/src/pages/Project/Details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<boolean>(() => {
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 = [
{
Expand All @@ -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<boolean>(() => {
return Boolean(matchSettings) || Boolean(matchEvents);
}, [matchSettings, matchEvents]);

return (
<div className={styles.page}>
<ContentLayout header={<DetailsHeader title={paramProjectName} />}>
{showTabs && <Tabs withNavigation tabs={tabs} />}
{showTabs && (
<Tabs
withNavigation
tabs={tabs}
activeTabId={matchQuotas ? 'quotas' : matchEvents ? 'events' : 'settings'}
/>
)}

<Outlet />
</ContentLayout>
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/pages/Project/components/QuotasTable/index.tsx
Original file line number Diff line number Diff line change
@@ -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: <h2>Quotas</h2>,
body: (
<>
<p>
These quotas apply per project to the backend with type <code>dstack</code> and are shared across its GPU
marketplace providers using {product.isSky ? 'Sky' : 'dstack Factory'} billing.
</p>
<p>
Each instance counts toward the quota by the CPU count shown in its offer. This count varies by instance type
and backend.
</p>
<p>Backends connected to your own cloud accounts use your cloud provider's quotas.</p>
<p>
To request an increase, ask in <code>#help</code> on{' '}
<a href={DISCORD_URL} target="_blank" rel="noopener noreferrer">
Discord
</a>
.
</p>
</>
),
};

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 (
<div id="project-quotas">
<Table
header={
<Header
variant="h2"
info={<InfoLink ariaLabel="Quota information" onFollow={() => openHelpPanel(QUOTA_INFO)} />}
actions={
<Button
href={DISCORD_URL}
target="_blank"
iconName="external"
ariaLabel="Request quota increase on Discord"
>
Increase
</Button>
}
>
Quotas
</Header>
}
columnDefinitions={[
{ id: 'type', header: 'Type', cell: (item) => (item.name === 'cpu' ? 'vCPUs' : item.name) },
{
id: 'value',
header: 'Value',
cell: (item) =>
isFetching && !quotas ? (
<Spinner />
) : item.value === undefined ? (
'—'
) : item.value === null ? (
'No limit'
) : (
item.value.toLocaleString()
),
},
]}
items={items}
footer={error ? <Alert type="error">Unable to load quotas.</Alert> : undefined}
/>
</div>
);
};
1 change: 1 addition & 0 deletions frontend/src/pages/Project/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,6 +129,10 @@ export const router = createBrowserRouter([
index: true,
element: <ProjectSettings />,
},
product.hasBilling && {
path: ROUTES.PROJECT.DETAILS.QUOTAS.TEMPLATE,
element: <ProjectQuotas />,
},
product.hasEvents && {
path: ROUTES.PROJECT.DETAILS.EVENTS.TEMPLATE,
element: <ProjectEvents />,
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/services/backendQuota.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { API } from 'api';

import { projectApi } from './project';

export const backendQuotaApi = projectApi.injectEndpoints({
endpoints: (builder) => ({
getBackendQuotas: builder.query<IBackendQuota[], { projectName: string; backendName: TBackendType }>({
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;
5 changes: 5 additions & 0 deletions frontend/src/types/backend.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ declare interface IProjectBackend {
name: string;
config: TBackendConfig;
}

declare interface IBackendQuota {
name: string;
value: number;
}
Loading