Compare commits
42 Commits
@nhost/vue
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
233b7e383e | ||
|
|
7ea469a1e3 | ||
|
|
ebd218c180 | ||
|
|
5ab1626f73 | ||
|
|
444c3b86ca | ||
|
|
7238412341 | ||
|
|
d8ceccec5d | ||
|
|
6db257d4c7 | ||
|
|
dfc18368be | ||
|
|
f7c6e80bf2 | ||
|
|
573cac1431 | ||
|
|
d72ae3f362 | ||
|
|
49ec7ec385 | ||
|
|
7d2b4083c2 | ||
|
|
696b493745 | ||
|
|
15a117a861 | ||
|
|
e7ff1f79f8 | ||
|
|
33c7368a2e | ||
|
|
664c182c8e | ||
|
|
c1ab4e0a77 | ||
|
|
4a4bd61757 | ||
|
|
b6d05289be | ||
|
|
5857458ca5 | ||
|
|
2fb1145fe0 | ||
|
|
546d710102 | ||
|
|
7756103476 | ||
|
|
fef9456c12 | ||
|
|
2d6d56f6b0 | ||
|
|
f54be0fefd | ||
|
|
4e76d388ab | ||
|
|
84b84ab785 | ||
|
|
ed66769688 | ||
|
|
a0298e0bdb | ||
|
|
3fd94b1cdf | ||
|
|
61d5f7d616 | ||
|
|
cde9a0a715 | ||
|
|
eae6349b04 | ||
|
|
211b930b84 | ||
|
|
4ae463074b | ||
|
|
1c5a4746f7 | ||
|
|
d6ae1fa44a | ||
|
|
a3abb81b37 |
@@ -1,5 +1,21 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2fb1145f: feat(compute): add support for replicas
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d8ceccec: chore(env): remove deprecated `NHOST_BACKEND_URL` environment variable
|
||||
|
||||
## 0.15.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 84b84ab7: fix(projects): filter projects by workspace
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -147,7 +147,7 @@
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.30.0",
|
||||
"vitest": "^0.30.1",
|
||||
"webpack": "^5.75.0"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -103,7 +103,7 @@ function ProjectLayoutContent({
|
||||
>
|
||||
{children}
|
||||
|
||||
<NextSeo title={currentProject.name} />
|
||||
<NextSeo title={currentProject?.name} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { mockApplication, mockWorkspace } from '@/tests/mocks';
|
||||
import { queryClient, render, screen } from '@/tests/testUtils';
|
||||
import type { Project } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
@@ -35,43 +33,6 @@ vi.mock('next/router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockApplication: Project = {
|
||||
id: '1',
|
||||
name: 'Test Application',
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
awsName: 'us-east-1',
|
||||
city: 'New York',
|
||||
countryCode: 'US',
|
||||
id: '1',
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
deployments: [],
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
providersUpdated: true,
|
||||
githubRepository: { fullName: 'test/git-project' },
|
||||
repositoryProductionBranch: null,
|
||||
nhostBaseFolder: null,
|
||||
plan: null,
|
||||
config: {
|
||||
hasura: {
|
||||
adminSecret: 'nhost-admin-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
slug: 'test-workspace',
|
||||
members: [],
|
||||
applications: [mockApplication],
|
||||
};
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
|
||||
@@ -22,7 +22,6 @@ import generateAppServiceUrl, {
|
||||
defaultRemoteBackendSlugs,
|
||||
} from '@/utils/common/generateAppServiceUrl';
|
||||
import { getHasuraConsoleServiceUrl } from '@/utils/env';
|
||||
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||
import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
@@ -99,10 +98,6 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
}
|
||||
|
||||
const systemEnvironmentVariables = [
|
||||
{
|
||||
key: 'NHOST_BACKEND_URL',
|
||||
value: generateRemoteAppUrl(currentProject.subdomain),
|
||||
},
|
||||
{ key: 'NHOST_SUBDOMAIN', value: currentProject.subdomain },
|
||||
{ key: 'NHOST_REGION', value: currentProject.region.awsName },
|
||||
{
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import useProPlan from '@/hooks/common/useProPlan';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import { InfoIcon } from '@/ui/v2/icons/InfoIcon';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
RESOURCE_VCPU_PRICE_PER_MINUTE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
|
||||
export interface ResourcesConfirmationDialogProps {
|
||||
/**
|
||||
* Price of the new plan.
|
||||
* The updated resources that the user has selected.
|
||||
*/
|
||||
updatedResources: {
|
||||
vcpu: number;
|
||||
memory: number;
|
||||
};
|
||||
formValues: ResourceSettingsFormValues;
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
@@ -30,13 +32,47 @@ export interface ResourcesConfirmationDialogProps {
|
||||
}
|
||||
|
||||
export default function ResourcesConfirmationDialog({
|
||||
updatedResources,
|
||||
formValues,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ResourcesConfirmationDialogProps) {
|
||||
const { data: proPlan, loading, error } = useProPlan();
|
||||
|
||||
const priceForTotalAvailableVCPU =
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const totalBillableVCPU = formValues.enabled ? billableResources.vcpu : 0;
|
||||
const totalBillableMemory = formValues.enabled ? billableResources.memory : 0;
|
||||
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE * (updatedResources.vcpu / RESOURCE_VCPU_MULTIPLIER);
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
|
||||
if (!loading && !proPlan) {
|
||||
return (
|
||||
@@ -50,9 +86,22 @@ export default function ResourcesConfirmationDialog({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const databaseResources = `${prettifyVCPU(
|
||||
formValues.database.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.database.memory)}`;
|
||||
const hasuraResources = `${prettifyVCPU(
|
||||
formValues.hasura.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.hasura.memory)}`;
|
||||
const authResources = `${prettifyVCPU(
|
||||
formValues.auth.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.auth.memory)}`;
|
||||
const storageResources = `${prettifyVCPU(
|
||||
formValues.storage.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.storage.memory)}`;
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
{updatedResources.vcpu > 0 ? (
|
||||
{totalBillableVCPU > 0 ? (
|
||||
<Text className="text-center">
|
||||
Please allow some time for the selected resources to take effect.
|
||||
</Text>
|
||||
@@ -69,28 +118,96 @@ export default function ResourcesConfirmationDialog({
|
||||
<Text>${proPlan.price.toFixed(2)}/mo</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text className="font-medium">Dedicated Resources</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{prettifyVCPU(updatedResources.vcpu)} vCPUs +{' '}
|
||||
{prettifyMemory(updatedResources.memory)} of Memory
|
||||
<Box className="grid grid-flow-row gap-1.5">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text className="font-medium">Dedicated Resources</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
$
|
||||
{(
|
||||
(totalBillableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE_PER_MINUTE
|
||||
).toFixed(4)}
|
||||
/min
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>${updatedPrice.toFixed(2)}/mo</Text>
|
||||
|
||||
<Box className="grid w-full grid-flow-row gap-1.5">
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
PostgreSQL Database
|
||||
</Text>
|
||||
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.database.replicas > 1
|
||||
? `${databaseResources} (${formValues.database.replicas} replicas)`
|
||||
: databaseResources}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
Hasura GraphQL
|
||||
</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.hasura.replicas > 1
|
||||
? `${hasuraResources} (${formValues.hasura.replicas} replicas)`
|
||||
: hasuraResources}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
Auth
|
||||
</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.auth.replicas > 1
|
||||
? `${authResources} (${formValues.auth.replicas} replicas)`
|
||||
: authResources}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
Storage
|
||||
</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.storage.replicas > 1
|
||||
? `${storageResources} (${formValues.storage.replicas} replicas)`
|
||||
: storageResources}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs font-medium" color="secondary">
|
||||
Total
|
||||
</Text>
|
||||
<Text className="text-xs font-medium" color="secondary">
|
||||
{prettifyVCPU(totalBillableVCPU)} vCPU +{' '}
|
||||
{prettifyMemory(totalBillableMemory)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="font-medium">Total</Text>
|
||||
<Text>${(updatedPrice + proPlan.price).toFixed(2)}/mo</Text>
|
||||
<Box className="grid grid-flow-col items-center gap-1.5">
|
||||
<Text className="font-medium">Approximate Cost</Text>
|
||||
|
||||
<Tooltip title="$0.0012/minute for every 1 vCPU and 2 GiB of RAM">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Text>${updatedPrice.toFixed(2)}/mo</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color={updatedResources.vcpu > 0 ? 'primary' : 'error'}
|
||||
color={totalBillableVCPU > 0 ? 'primary' : 'error'}
|
||||
onClick={onSubmit}
|
||||
autoFocus
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mockMatchMediaValue, mockRouter } from '@/tests/mocks';
|
||||
import {
|
||||
getProPlanOnlyQuery,
|
||||
getWorkspaceAndProjectQuery,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from '@/tests/testUtils';
|
||||
@@ -22,49 +22,16 @@ import {
|
||||
} from '@/utils/CONSTANTS';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import ResourcesForm from './ResourcesForm';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
basePath: '',
|
||||
pathname: '/test-workspace/test-application',
|
||||
route: '/[workspaceSlug]/[appSlug]',
|
||||
asPath: '/test-workspace/test-application',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {
|
||||
workspaceSlug: 'test-workspace',
|
||||
appSlug: 'test-application',
|
||||
},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
back: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
beforePopState: vi.fn(),
|
||||
events: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
isFallback: false,
|
||||
}),
|
||||
useRouter: vi.fn().mockReturnValue(mockRouter),
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
@@ -79,7 +46,10 @@ beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Note: Workaround based on https://github.com/testing-library/user-event/issues/871#issuecomment-1059317998
|
||||
function changeSliderValue(slider: HTMLElement, value: number) {
|
||||
@@ -92,9 +62,7 @@ test('should show an empty state message that the feature must be enabled if no
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/enable this feature/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show the sliders if the switch is enabled', async () => {
|
||||
@@ -103,28 +71,23 @@ test('should show the sliders if the switch is enabled', async () => {
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/enable this feature/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(9);
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(12);
|
||||
});
|
||||
|
||||
test('should not show an empty state message if there is data available', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(9);
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(12);
|
||||
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 8/i);
|
||||
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 16384 mib/i);
|
||||
});
|
||||
@@ -132,7 +95,9 @@ test('should not show an empty state message if there is data available', async
|
||||
test('should show a warning message if not all the resources are allocated', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
@@ -145,14 +110,16 @@ test('should show a warning message if not all the resources are allocated', asy
|
||||
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 18432 mib/i);
|
||||
|
||||
expect(
|
||||
screen.getByText(/you now have 1 vcpus and 2048 mib of memory unused./i),
|
||||
screen.getByText(/you have 1 vcpus and 2048 mib of memory unused./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should update the price when the top slider is changed', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(/\$200\.00\/mo/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -172,7 +139,9 @@ test('should show a validation error when the form is submitted when not everyth
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
|
||||
@@ -186,8 +155,10 @@ test('should show a validation error when the form is submitted when not everyth
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(
|
||||
screen.getAllByText(/you now have 1 vcpus and 2048 mib of memory unused./i),
|
||||
).toHaveLength(2);
|
||||
screen.getByText(/you have 1 vcpus and 2048 mib of memory unused./i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/invalid configuration/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show a confirmation dialog when the form is submitted', async () => {
|
||||
@@ -196,12 +167,9 @@ test('should show a confirmation dialog when the form is submitted', async () =>
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
@@ -212,36 +180,36 @@ test('should show a confirmation dialog when the form is submitted', async () =>
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
2 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
2.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
1.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
3 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
4.75 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
4.25 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
4 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
@@ -253,15 +221,21 @@ test('should show a confirmation dialog when the form is submitted', async () =>
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(
|
||||
/9 vcpus \+ 18432 mib of memory/i,
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
within(screen.getByRole('dialog')).getByText(/postgresql database/i)
|
||||
.parentElement,
|
||||
).toHaveTextContent(/2 vcpu \+ 4864 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/\$475\.00\/mo/i, {
|
||||
exact: true,
|
||||
}),
|
||||
within(screen.getByRole('dialog')).getByText(/hasura graphql/i)
|
||||
.parentElement,
|
||||
).toHaveTextContent(/2.5 vcpu \+ 4352 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/auth/i).parentElement,
|
||||
).toHaveTextContent(/1.5 vcpu \+ 4096 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/storage/i).parentElement,
|
||||
).toHaveTextContent(/3 vcpu \+ 5120 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/\$475\.00\/mo/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// we need to mock the query again because the mutation updated the resources
|
||||
@@ -271,6 +245,7 @@ test('should show a confirmation dialog when the form is submitted', async () =>
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('dialog'));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/resources have been updated successfully./i),
|
||||
).toBeInTheDocument();
|
||||
@@ -286,13 +261,15 @@ test('should display a red button when custom resources are disabled', async ()
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/total cost:/i)).toHaveTextContent(
|
||||
/total cost: \$25\.00\/mo/i,
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$25\.00\/mo/i,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
@@ -307,14 +284,16 @@ test('should display a red button when custom resources are disabled', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide the footer when custom resource allocation is disabled', async () => {
|
||||
test('should hide the pricing information when custom resource allocation is disabled', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
@@ -327,5 +306,201 @@ test('should hide the footer when custom resource allocation is disabled', async
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('dialog'));
|
||||
|
||||
expect(screen.queryByText(/total cost:/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/approximate cost:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show a warning message when resources are overallocated', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
7 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/^you have 1 vCPUs and 2048 mib of memory overallocated\. reduce it before saving or increase the total amount\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should change pricing based on selected replicas', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$425\.00\/mo/i,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$525\.00\/mo/i,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
|
||||
1,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$425\.00\/mo/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 replica is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
20 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage replicas/i }),
|
||||
2,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
1 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
6 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(screen.getByText(/invalid configuration/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please check the form for errors and the allocation for each service and try again\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const validationErrorMessage = screen.getByLabelText(
|
||||
/vcpu and memory for this service must match the 1:2 ratio if more than one replica is selected\./i,
|
||||
);
|
||||
|
||||
expect(validationErrorMessage).toBeInTheDocument();
|
||||
expect(validationErrorMessage).toHaveStyle({ color: '#f13154' });
|
||||
});
|
||||
|
||||
test('should take replicas into account when confirming the resources', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
8.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up database
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database vcpu/i }),
|
||||
2 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database memory/i }),
|
||||
4 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up hasura
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
|
||||
3,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
|
||||
2.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql memory/i }),
|
||||
5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up auth
|
||||
changeSliderValue(screen.getByRole('slider', { name: /auth replicas/i }), 2);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth vcpu/i }),
|
||||
1.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth memory/i }),
|
||||
3 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up storage
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage replicas/i }),
|
||||
4,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
2.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(/postgresql database/i).parentElement,
|
||||
).toHaveTextContent(/2 vcpu \+ 4096 mib/i);
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(/hasura graphql/i).parentElement,
|
||||
).toHaveTextContent(/2\.5 vcpu \+ 5120 mib \(3 replicas\)/i);
|
||||
|
||||
expect(within(dialog).getByText(/auth/i).parentElement).toHaveTextContent(
|
||||
/1\.5 vcpu \+ 3072 mib \(2 replicas\)/i,
|
||||
);
|
||||
|
||||
expect(within(dialog).getByText(/storage/i).parentElement).toHaveTextContent(
|
||||
/2\.5 vcpu \+ 5120 mib \(4 replicas\)/i,
|
||||
);
|
||||
|
||||
// total must contain the sum of all resources when replicas are taken into
|
||||
// account
|
||||
expect(within(dialog).getByText(/total/i).parentElement).toHaveTextContent(
|
||||
/22\.5 vcpu \+ 46080 mib/i,
|
||||
);
|
||||
|
||||
expect(within(dialog).getByText(/\$0.0270\/min/i)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/\$1150\.00\/mo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -4,18 +4,15 @@ import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import ResourcesConfirmationDialog from '@/components/settings/resources/ResourcesConfirmationDialog';
|
||||
import ServiceResourcesFormFragment from '@/components/settings/resources/ServiceResourcesFormFragment';
|
||||
import TotalResourcesFormFragment from '@/components/settings/resources/TotalResourcesFormFragment';
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { resourceSettingsValidationSchema } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import useProPlan from '@/hooks/common/useProPlan';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
@@ -27,29 +24,27 @@ import {
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUnallocatedResources from '@/utils/settings/getUnallocatedResources';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import ResourcesFormFooter from './ResourcesFormFooter';
|
||||
|
||||
function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { cpu, memory } = data?.config?.[service]?.resources?.compute || {};
|
||||
const { compute, replicas } = data?.config?.[service]?.resources || {};
|
||||
|
||||
return {
|
||||
vcpu: cpu || 0,
|
||||
memory: memory || 0,
|
||||
replicas,
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResourcesForm() {
|
||||
const [validationError, setValidationError] = useState<Error | null>(null);
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
@@ -95,14 +90,26 @@ export default function ResourcesForm() {
|
||||
enabled: totalInitialVCPU > 0 && totalInitialMemory > 0,
|
||||
totalAvailableVCPU: totalInitialVCPU || 2000,
|
||||
totalAvailableMemory: totalInitialMemory || 4096,
|
||||
hasuraVCPU: initialHasuraResources.vcpu || 500,
|
||||
hasuraMemory: initialHasuraResources.memory || 1536,
|
||||
databaseVCPU: initialDatabaseResources.vcpu || 1000,
|
||||
databaseMemory: initialDatabaseResources.memory || 2048,
|
||||
authVCPU: initialAuthResources.vcpu || 250,
|
||||
authMemory: initialAuthResources.memory || 256,
|
||||
storageVCPU: initialStorageResources.vcpu || 250,
|
||||
storageMemory: initialStorageResources.memory || 256,
|
||||
database: {
|
||||
replicas: initialDatabaseResources.replicas || 1,
|
||||
vcpu: initialDatabaseResources.vcpu || 1000,
|
||||
memory: initialDatabaseResources.memory || 2048,
|
||||
},
|
||||
hasura: {
|
||||
replicas: initialHasuraResources.replicas || 1,
|
||||
vcpu: initialHasuraResources.vcpu || 500,
|
||||
memory: initialHasuraResources.memory || 1536,
|
||||
},
|
||||
auth: {
|
||||
replicas: initialAuthResources.replicas || 1,
|
||||
vcpu: initialAuthResources.vcpu || 250,
|
||||
memory: initialAuthResources.memory || 256,
|
||||
},
|
||||
storage: {
|
||||
replicas: initialStorageResources.replicas || 1,
|
||||
vcpu: initialStorageResources.vcpu || 250,
|
||||
memory: initialStorageResources.memory || 256,
|
||||
},
|
||||
},
|
||||
resolver: yupResolver(resourceSettingsValidationSchema),
|
||||
});
|
||||
@@ -127,16 +134,32 @@ export default function ResourcesForm() {
|
||||
|
||||
const { watch, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
const hasFormErrors = Object.keys(formState.errors).length > 0;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
const totalAvailableVCPU = enabled ? watch('totalAvailableVCPU') : 0;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: initialDatabaseResources.replicas,
|
||||
vcpu: initialDatabaseResources.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: initialHasuraResources.replicas,
|
||||
vcpu: initialHasuraResources.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: initialAuthResources.replicas,
|
||||
vcpu: initialAuthResources.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: initialStorageResources.replicas,
|
||||
vcpu: initialStorageResources.vcpu,
|
||||
},
|
||||
);
|
||||
|
||||
const initialPrice =
|
||||
RESOURCE_VCPU_PRICE * (totalInitialVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE * (totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
proPlan.price +
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE;
|
||||
|
||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
@@ -144,46 +167,46 @@ export default function ResourcesForm() {
|
||||
appId: currentProject?.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: enabled
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.databaseVCPU,
|
||||
memory: formValues.databaseMemory,
|
||||
cpu: formValues.database.vcpu,
|
||||
memory: formValues.database.memory,
|
||||
},
|
||||
replicas: 1,
|
||||
replicas: formValues.database.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hasura: {
|
||||
resources: enabled
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.hasuraVCPU,
|
||||
memory: formValues.hasuraMemory,
|
||||
cpu: formValues.hasura.vcpu,
|
||||
memory: formValues.hasura.memory,
|
||||
},
|
||||
replicas: 1,
|
||||
replicas: formValues.hasura.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
auth: {
|
||||
resources: enabled
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.authVCPU,
|
||||
memory: formValues.authMemory,
|
||||
cpu: formValues.auth.vcpu,
|
||||
memory: formValues.auth.memory,
|
||||
},
|
||||
replicas: 1,
|
||||
replicas: formValues.auth.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
storage: {
|
||||
resources: enabled
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.storageVCPU,
|
||||
memory: formValues.storageMemory,
|
||||
cpu: formValues.storage.vcpu,
|
||||
memory: formValues.storage.memory,
|
||||
},
|
||||
replicas: 1,
|
||||
replicas: formValues.storage.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
@@ -209,14 +232,26 @@ export default function ResourcesForm() {
|
||||
enabled: false,
|
||||
totalAvailableVCPU: 2000,
|
||||
totalAvailableMemory: 4096,
|
||||
hasuraVCPU: 500,
|
||||
hasuraMemory: 1536,
|
||||
databaseVCPU: 1000,
|
||||
databaseMemory: 2048,
|
||||
authVCPU: 250,
|
||||
authMemory: 256,
|
||||
storageVCPU: 250,
|
||||
storageMemory: 256,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 1000,
|
||||
memory: 2048,
|
||||
},
|
||||
hasura: {
|
||||
replicas: 1,
|
||||
vcpu: 500,
|
||||
memory: 1536,
|
||||
},
|
||||
auth: {
|
||||
replicas: 1,
|
||||
vcpu: 250,
|
||||
memory: 256,
|
||||
},
|
||||
storage: {
|
||||
replicas: 1,
|
||||
vcpu: 250,
|
||||
memory: 256,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
form.reset(null, { keepValues: true, keepDirty: false });
|
||||
@@ -227,41 +262,13 @@ export default function ResourcesForm() {
|
||||
}
|
||||
|
||||
function handleConfirm(formValues: ResourceSettingsFormValues) {
|
||||
setValidationError(null);
|
||||
|
||||
const { vcpu: unallocatedVCPU, memory: unallocatedMemory } =
|
||||
getUnallocatedResources(formValues);
|
||||
const hasUnusedResources = unallocatedVCPU > 0 || unallocatedMemory > 0;
|
||||
|
||||
if (hasUnusedResources) {
|
||||
const unusedResourceMessage = [
|
||||
unallocatedVCPU > 0 ? `${prettifyVCPU(unallocatedVCPU)} vCPUs` : '',
|
||||
unallocatedMemory > 0
|
||||
? `${prettifyMemory(unallocatedMemory)} of Memory`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
setValidationError(
|
||||
new Error(
|
||||
`You now have ${unusedResourceMessage} unused. Allocate it to any of the services before saving.`,
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openDialog({
|
||||
title: enabled
|
||||
title: formValues.enabled
|
||||
? 'Confirm Dedicated Resources'
|
||||
: 'Disable Dedicated Resources',
|
||||
component: (
|
||||
<ResourcesConfirmationDialog
|
||||
updatedResources={{
|
||||
vcpu: enabled ? formValues.totalAvailableVCPU : 0,
|
||||
memory: enabled ? formValues.totalAvailableMemory : 0,
|
||||
}}
|
||||
formValues={formValues}
|
||||
onCancel={closeDialog}
|
||||
onSubmit={async () => {
|
||||
await handleSubmit(formValues);
|
||||
@@ -304,47 +311,46 @@ export default function ResourcesForm() {
|
||||
<ServiceResourcesFormFragment
|
||||
title="PostgreSQL Database"
|
||||
description="Manage how much compute you need for the PostgreSQL Database."
|
||||
cpuKey="databaseVCPU"
|
||||
memoryKey="databaseMemory"
|
||||
serviceKey="database"
|
||||
disableReplicas
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Hasura GraphQL"
|
||||
description="Manage how much compute you need for the Hasura GraphQL API."
|
||||
cpuKey="hasuraVCPU"
|
||||
memoryKey="hasuraMemory"
|
||||
serviceKey="hasura"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Auth"
|
||||
description="Manage how much compute you need for Auth."
|
||||
cpuKey="authVCPU"
|
||||
memoryKey="authMemory"
|
||||
serviceKey="auth"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Storage"
|
||||
description="Manage how much compute you need for Storage."
|
||||
cpuKey="storageVCPU"
|
||||
memoryKey="storageMemory"
|
||||
serviceKey="storage"
|
||||
/>
|
||||
{validationError && (
|
||||
|
||||
{hasFormErrors && (
|
||||
<Box className="px-4 pb-4">
|
||||
<Alert
|
||||
severity="error"
|
||||
className="flex flex-col gap-2 text-left"
|
||||
>
|
||||
<strong>
|
||||
Please use all the available vCPUs and Memory
|
||||
</strong>
|
||||
<strong>Invalid Configuration</strong>
|
||||
|
||||
<p>{validationError.message}</p>
|
||||
<p>
|
||||
Please check the form for errors and the allocation for
|
||||
each service and try again.
|
||||
</p>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box className={twMerge('px-4', (enabled || isDirty) && 'pb-4')}>
|
||||
<Box className={twMerge('px-4', 'pb-4')}>
|
||||
<Alert className="text-left">
|
||||
Enable this feature to access custom resource allocation for
|
||||
your services.
|
||||
@@ -352,29 +358,7 @@ export default function ResourcesForm() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(enabled || isDirty) && (
|
||||
<Box className="flex flex-row items-center justify-between border-t px-4 pt-4">
|
||||
<span />
|
||||
|
||||
<Box className="flex flex-row items-center gap-4">
|
||||
<Text>
|
||||
Total cost:{' '}
|
||||
<span className="font-medium">
|
||||
${updatedPrice.toFixed(2)}/mo
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant={isDirty ? 'contained' : 'outlined'}
|
||||
color={isDirty ? 'primary' : 'secondary'}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<ResourcesFormFooter />
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { InfoIcon } from '@/ui/v2/icons/InfoIcon';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import { useFormState, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ResourcesFormFooter() {
|
||||
const {
|
||||
data: proPlan,
|
||||
loading: proPlanLoading,
|
||||
error: proPlanError,
|
||||
} = useProPlan();
|
||||
|
||||
const formState = useFormState<ResourceSettingsFormValues>();
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const enabled = useWatch<ResourceSettingsFormValues>({ name: 'enabled' });
|
||||
const [totalAvailableVCPU, database, hasura, auth, storage] = useWatch<
|
||||
ResourceSettingsFormValues,
|
||||
['totalAvailableVCPU', 'database', 'hasura', 'auth', 'storage']
|
||||
>({
|
||||
name: ['totalAvailableVCPU', 'database', 'hasura', 'auth', 'storage'],
|
||||
});
|
||||
|
||||
if (proPlanLoading) {
|
||||
return <ActivityIndicator label="Loading plan details..." delay={1000} />;
|
||||
}
|
||||
|
||||
if (proPlanError) {
|
||||
throw proPlanError;
|
||||
}
|
||||
|
||||
const priceForTotalAvailableVCPU =
|
||||
(totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: database?.replicas,
|
||||
vcpu: database?.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: hasura?.replicas,
|
||||
vcpu: hasura?.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: auth?.replicas,
|
||||
vcpu: auth?.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: storage?.replicas,
|
||||
vcpu: storage?.vcpu,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice = enabled
|
||||
? Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price
|
||||
: proPlan.price;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid items-center gap-4 border-t px-4 pt-4 lg:grid-flow-col lg:justify-between lg:gap-2"
|
||||
component="footer"
|
||||
>
|
||||
<Text>
|
||||
Learn more about{' '}
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/compute"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
Compute Resources
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
{(enabled || isDirty) && (
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-4">
|
||||
<Box className="grid grid-flow-col items-center gap-1.5">
|
||||
<Text>
|
||||
Approximate cost:{' '}
|
||||
<span className="font-medium">${updatedPrice.toFixed(2)}/mo</span>
|
||||
</Text>
|
||||
|
||||
<Tooltip title="$0.0012/minute for every 1 vCPU and 2 GiB of RAM">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant={isDirty ? 'contained' : 'outlined'}
|
||||
color={isDirty ? 'primary' : 'secondary'}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import {
|
||||
MAX_SERVICE_MEMORY,
|
||||
MAX_SERVICE_REPLICAS,
|
||||
MAX_SERVICE_VCPU,
|
||||
MIN_SERVICE_MEMORY,
|
||||
MIN_SERVICE_REPLICAS,
|
||||
MIN_SERVICE_VCPU,
|
||||
} from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Slider from '@/ui/v2/Slider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import { ExclamationIcon } from '@/ui/v2/icons/ExclamationIcon';
|
||||
import { RESOURCE_MEMORY_STEP, RESOURCE_VCPU_STEP } from '@/utils/CONSTANTS';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
@@ -23,79 +27,98 @@ export interface ServiceResourcesFormFragmentProps {
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Form field name for CPU.
|
||||
* Form field name for service.
|
||||
*/
|
||||
cpuKey: Exclude<
|
||||
serviceKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
/**
|
||||
* Form field name for Memory.
|
||||
* Whether to disable the replicas field.
|
||||
*/
|
||||
memoryKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
disableReplicas?: boolean;
|
||||
}
|
||||
|
||||
export default function ServiceResourcesFormFragment({
|
||||
title,
|
||||
description,
|
||||
cpuKey,
|
||||
memoryKey,
|
||||
serviceKey,
|
||||
disableReplicas = false,
|
||||
}: ServiceResourcesFormFragmentProps) {
|
||||
const { setValue } = useFormContext<ResourceSettingsFormValues>();
|
||||
const {
|
||||
setValue,
|
||||
trigger: triggerValidation,
|
||||
formState,
|
||||
} = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
const serviceValues = formValues[serviceKey];
|
||||
|
||||
// Total allocated CPU for all resources
|
||||
const totalAllocatedCPU = Object.keys(formValues)
|
||||
.filter((key) => key.endsWith('CPU') && key !== 'totalAvailableVCPU')
|
||||
.reduce((acc, key) => acc + formValues[key], 0);
|
||||
const totalAllocatedVCPU = Object.keys(formValues)
|
||||
.filter(
|
||||
(key) =>
|
||||
!['enabled', 'totalAvailableVCPU', 'totalAvailableMemory'].includes(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.reduce((acc, key) => acc + formValues[key].vcpu, 0);
|
||||
|
||||
// Total allocated memory for all resources
|
||||
const totalAllocatedMemory = Object.keys(formValues)
|
||||
.filter((key) => key.endsWith('Memory') && key !== 'totalAvailableMemory')
|
||||
.reduce((acc, key) => acc + formValues[key], 0);
|
||||
.filter(
|
||||
(key) =>
|
||||
!['enabled', 'totalAvailableVCPU', 'totalAvailableMemory'].includes(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.reduce((acc, key) => acc + formValues[key].memory, 0);
|
||||
|
||||
const remainingCPU = formValues.totalAvailableVCPU - totalAllocatedCPU;
|
||||
const allowedCPU = remainingCPU + formValues[cpuKey];
|
||||
const remainingVCPU = formValues.totalAvailableVCPU - totalAllocatedVCPU;
|
||||
const allowedVCPU = remainingVCPU + serviceValues.vcpu;
|
||||
|
||||
const remainingMemory =
|
||||
formValues.totalAvailableMemory - totalAllocatedMemory;
|
||||
const allowedMemory = remainingMemory + formValues[memoryKey];
|
||||
const allowedMemory = remainingMemory + serviceValues.memory;
|
||||
|
||||
function handleCPUChange(value: string) {
|
||||
const updatedCPU = parseFloat(value);
|
||||
const exceedsAvailableCPU =
|
||||
updatedCPU + (totalAllocatedCPU - formValues[cpuKey]) >
|
||||
formValues.totalAvailableVCPU;
|
||||
function handleReplicaChange(value: string) {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedCPU) ||
|
||||
exceedsAvailableCPU ||
|
||||
updatedCPU < MIN_SERVICE_VCPU
|
||||
) {
|
||||
if (updatedReplicas < MIN_SERVICE_REPLICAS) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(cpuKey, updatedCPU, { shouldDirty: true });
|
||||
setValue(`${serviceKey}.replicas`, updatedReplicas, { shouldDirty: true });
|
||||
triggerValidation(`${serviceKey}.replicas`);
|
||||
}
|
||||
|
||||
function handleVCPUChange(value: string) {
|
||||
const updatedVCPU = parseFloat(value);
|
||||
|
||||
if (Number.isNaN(updatedVCPU) || updatedVCPU < MIN_SERVICE_VCPU) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(`${serviceKey}.vcpu`, updatedVCPU, { shouldDirty: true });
|
||||
|
||||
// trigger validation for "replicas" field
|
||||
if (!disableReplicas) {
|
||||
triggerValidation(`${serviceKey}.replicas`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMemoryChange(value: string) {
|
||||
const updatedMemory = parseFloat(value);
|
||||
const exceedsAvailableMemory =
|
||||
updatedMemory + (totalAllocatedMemory - formValues[memoryKey]) >
|
||||
formValues.totalAvailableMemory;
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedMemory) ||
|
||||
exceedsAvailableMemory ||
|
||||
updatedMemory < MIN_SERVICE_MEMORY
|
||||
) {
|
||||
if (Number.isNaN(updatedMemory) || updatedMemory < MIN_SERVICE_MEMORY) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(memoryKey, updatedMemory, { shouldDirty: true });
|
||||
setValue(`${serviceKey}.memory`, updatedMemory, { shouldDirty: true });
|
||||
|
||||
// trigger validation for "replicas" field
|
||||
if (!disableReplicas) {
|
||||
triggerValidation(`${serviceKey}.replicas`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -113,14 +136,14 @@ export default function ServiceResourcesFormFragment({
|
||||
<Text>
|
||||
Allocated vCPUs:{' '}
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(formValues[cpuKey])}
|
||||
{prettifyVCPU(serviceValues.vcpu)}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingCPU > 0 && formValues[cpuKey] < MAX_SERVICE_VCPU && (
|
||||
{remainingVCPU > 0 && serviceValues.vcpu < MAX_SERVICE_VCPU && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(remainingCPU)} vCPUs
|
||||
{prettifyVCPU(remainingVCPU)} vCPUs
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
@@ -128,11 +151,11 @@ export default function ServiceResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={formValues[cpuKey]}
|
||||
onChange={(_event, value) => handleCPUChange(value.toString())}
|
||||
value={serviceValues.vcpu}
|
||||
onChange={(_event, value) => handleVCPUChange(value.toString())}
|
||||
max={MAX_SERVICE_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
allowed={allowedCPU}
|
||||
allowed={allowedVCPU}
|
||||
aria-label={`${title} vCPU`}
|
||||
marks
|
||||
/>
|
||||
@@ -143,11 +166,11 @@ export default function ServiceResourcesFormFragment({
|
||||
<Text>
|
||||
Allocated Memory:{' '}
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(formValues[memoryKey])}
|
||||
{prettifyMemory(serviceValues.memory)}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingMemory > 0 && formValues[memoryKey] < MAX_SERVICE_MEMORY && (
|
||||
{remainingMemory > 0 && serviceValues.memory < MAX_SERVICE_MEMORY && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(remainingMemory)} of Memory
|
||||
@@ -158,7 +181,7 @@ export default function ServiceResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={formValues[memoryKey]}
|
||||
value={serviceValues.memory}
|
||||
onChange={(_event, value) => handleMemoryChange(value.toString())}
|
||||
max={MAX_SERVICE_MEMORY}
|
||||
step={RESOURCE_MEMORY_STEP}
|
||||
@@ -167,6 +190,47 @@ export default function ServiceResourcesFormFragment({
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!disableReplicas && (
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-start gap-2">
|
||||
<Text
|
||||
color={
|
||||
formState.errors?.[serviceKey]?.replicas?.message
|
||||
? 'error'
|
||||
: 'primary'
|
||||
}
|
||||
aria-errormessage={`${serviceKey}-replicas-error-tooltip`}
|
||||
>
|
||||
Replicas:{' '}
|
||||
<span className="font-medium">{serviceValues.replicas}</span>
|
||||
</Text>
|
||||
|
||||
{formState.errors?.[serviceKey]?.replicas?.message ? (
|
||||
<Tooltip
|
||||
title={formState.errors[serviceKey].replicas.message}
|
||||
id={`${serviceKey}-replicas-error-tooltip`}
|
||||
>
|
||||
<ExclamationIcon
|
||||
color="error"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={serviceValues.replicas}
|
||||
onChange={(_event, value) => handleReplicaChange(value.toString())}
|
||||
min={0}
|
||||
max={MAX_SERVICE_REPLICAS}
|
||||
step={1}
|
||||
aria-label={`${title} Replicas`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import { getAllocatedResources } from '@/features/settings/resources/utils/getAllocatedResources';
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import {
|
||||
MAX_TOTAL_VCPU,
|
||||
MIN_TOTAL_MEMORY,
|
||||
MIN_TOTAL_VCPU,
|
||||
} from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import useProPlan from '@/hooks/common/useProPlan';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Slider, { sliderClasses } from '@/ui/v2/Slider';
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
RESOURCE_VCPU_PRICE,
|
||||
RESOURCE_VCPU_STEP,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import getUnallocatedResources from '@/utils/settings/getUnallocatedResources';
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
@@ -59,51 +59,72 @@ export default function TotalResourcesFormFragment({
|
||||
throw proPlanError;
|
||||
}
|
||||
|
||||
const allocatedCPU =
|
||||
formValues.databaseVCPU +
|
||||
formValues.hasuraVCPU +
|
||||
formValues.authVCPU +
|
||||
formValues.storageVCPU;
|
||||
const allocatedMemory =
|
||||
formValues.databaseMemory +
|
||||
formValues.hasuraMemory +
|
||||
formValues.authMemory +
|
||||
formValues.storageMemory;
|
||||
const priceForTotalAvailableVCPU =
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE *
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
|
||||
const { vcpu: unallocatedVCPU, memory: unallocatedMemory } =
|
||||
getUnallocatedResources(formValues);
|
||||
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
|
||||
getAllocatedResources(formValues);
|
||||
const remainingVCPU = formValues.totalAvailableVCPU - allocatedVCPU;
|
||||
const remainingMemory = formValues.totalAvailableMemory - allocatedMemory;
|
||||
const hasUnusedResources = remainingVCPU > 0 || remainingMemory > 0;
|
||||
const hasOverallocatedResources = remainingVCPU < 0 || remainingMemory < 0;
|
||||
|
||||
const hasUnusedResources = unallocatedVCPU > 0 || unallocatedMemory > 0;
|
||||
const unusedResourceMessage = [
|
||||
unallocatedVCPU > 0 ? `${prettifyVCPU(unallocatedVCPU)} vCPUs` : '',
|
||||
unallocatedMemory > 0
|
||||
? `${prettifyMemory(unallocatedMemory)} of Memory`
|
||||
: '',
|
||||
remainingVCPU > 0 ? `${prettifyVCPU(remainingVCPU)} vCPUs` : '',
|
||||
remainingMemory > 0 ? `${prettifyMemory(remainingMemory)} of Memory` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
function handleCPUChange(value: string) {
|
||||
const updatedCPU = parseFloat(value);
|
||||
const overallocatedResourceMessage = [
|
||||
remainingVCPU < 0 ? `${prettifyVCPU(-remainingVCPU)} vCPUs` : '',
|
||||
remainingMemory < 0 ? `${prettifyMemory(-remainingMemory)} of Memory` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
function handleVCPUChange(value: string) {
|
||||
const updatedVCPU = parseFloat(value);
|
||||
const updatedMemory =
|
||||
(updatedCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
(updatedVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedCPU) ||
|
||||
updatedCPU < Math.max(MIN_TOTAL_VCPU, allocatedCPU) ||
|
||||
updatedMemory < Math.max(MIN_TOTAL_MEMORY, allocatedMemory)
|
||||
) {
|
||||
if (Number.isNaN(updatedVCPU) || updatedVCPU < MIN_TOTAL_VCPU) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('totalAvailableVCPU', updatedCPU, { shouldDirty: true });
|
||||
setValue('totalAvailableVCPU', updatedVCPU, { shouldDirty: true });
|
||||
setValue('totalAvailableMemory', updatedMemory, { shouldDirty: true });
|
||||
}
|
||||
|
||||
@@ -147,7 +168,7 @@ export default function TotalResourcesFormFragment({
|
||||
|
||||
<StyledAvailableCpuSlider
|
||||
value={formValues.totalAvailableVCPU}
|
||||
onChange={(_event, value) => handleCPUChange(value.toString())}
|
||||
onChange={(_event, value) => handleVCPUChange(value.toString())}
|
||||
max={MAX_TOTAL_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
aria-label="Total Available vCPU"
|
||||
@@ -155,19 +176,34 @@ export default function TotalResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity={hasUnusedResources ? 'warning' : 'info'}
|
||||
severity={
|
||||
hasUnusedResources || hasOverallocatedResources ? 'warning' : 'info'
|
||||
}
|
||||
className="grid grid-flow-row gap-2 rounded-t-none rounded-b-[5px] text-left"
|
||||
>
|
||||
{hasUnusedResources ? (
|
||||
{hasUnusedResources && !hasOverallocatedResources && (
|
||||
<>
|
||||
<strong>Please use all the available vCPUs and Memory</strong>
|
||||
|
||||
<p>
|
||||
You now have {unusedResourceMessage} unused. Allocate it to any
|
||||
of the services before saving.
|
||||
You have {unusedResourceMessage} unused. Allocate it to any of
|
||||
the services before saving.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{hasOverallocatedResources && (
|
||||
<>
|
||||
<strong>Overallocated Resources</strong>
|
||||
|
||||
<p>
|
||||
You have {overallocatedResourceMessage} overallocated. Reduce it
|
||||
before saving or increase the total amount.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasUnusedResources && !hasOverallocatedResources && (
|
||||
<>
|
||||
<strong>You're All Set</strong>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const StyledSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
[`&:focus, &:hover, &.${materialSliderClasses.active}, &.${materialSliderClasses.focusVisible}`]:
|
||||
{
|
||||
boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.3)}`,
|
||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.35)}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IconProps } from '@/ui/v2/icons';
|
||||
import SvgIcon from '@/ui/v2/icons/SvgIcon';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function ExclamationIcon(props: IconProps, ref: ForwardedRef<SVGSVGElement>) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Exclamation mark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
opacity=".2"
|
||||
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.75 5.5V4h-1.5v5.5h1.5v-4Zm0 5.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ExclamationIcon.displayName = 'NhostExclamationIcon';
|
||||
|
||||
export default forwardRef(ExclamationIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ExclamationIcon } from './ExclamationIcon';
|
||||
@@ -0,0 +1,47 @@
|
||||
import calculateBillableResources from './calculateBillableResources';
|
||||
|
||||
test('should return zero if no services are provided', () => {
|
||||
expect(calculateBillableResources()).toMatchObject({ vcpu: 0, memory: 0 });
|
||||
});
|
||||
|
||||
test('should return the correct cost for a single service', () => {
|
||||
expect(
|
||||
calculateBillableResources({ replicas: 1, vcpu: 250, memory: 500 }),
|
||||
).toMatchObject({
|
||||
vcpu: 250,
|
||||
memory: 500,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return the correct cost for multiple services', () => {
|
||||
expect(
|
||||
calculateBillableResources(
|
||||
{ replicas: 1, vcpu: 250, memory: 250 },
|
||||
{ replicas: 1, vcpu: 250, memory: 500 },
|
||||
),
|
||||
).toMatchObject({ vcpu: 500, memory: 750 });
|
||||
});
|
||||
|
||||
test('should return the correct cost for multiple services with different vCPU and replica counts', () => {
|
||||
expect(
|
||||
calculateBillableResources(
|
||||
{ replicas: 2, vcpu: 250, memory: 500 },
|
||||
{ replicas: 1, vcpu: 500, memory: 750 },
|
||||
),
|
||||
).toMatchObject({ vcpu: 1000, memory: 1750 });
|
||||
});
|
||||
|
||||
test('should not count services with no replicas or vCPU and memory', () => {
|
||||
expect(
|
||||
calculateBillableResources(
|
||||
// should count
|
||||
{ replicas: 1, vcpu: 250 },
|
||||
// shouldn't count
|
||||
{ replicas: 1 },
|
||||
// shouldn't count
|
||||
{ vcpu: 250, memory: 1000 },
|
||||
// should count
|
||||
{ replicas: 1, memory: 500 },
|
||||
),
|
||||
).toMatchObject({ vcpu: 250, memory: 500 });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Calculate the approximate cost of a list of services.
|
||||
*
|
||||
* @param services - The list of services to calculate the cost of.
|
||||
* @returns The approximate cost of the services.
|
||||
*/
|
||||
export default function calculateBillableResources(
|
||||
...services: { replicas?: number; vcpu?: number; memory?: number }[]
|
||||
) {
|
||||
return services.reduce(
|
||||
(total, { replicas, vcpu, memory }) => {
|
||||
if (!replicas || (!vcpu && !memory)) {
|
||||
return total;
|
||||
}
|
||||
|
||||
if (!vcpu && memory) {
|
||||
return {
|
||||
...total,
|
||||
memory: total.memory + memory * replicas,
|
||||
};
|
||||
}
|
||||
|
||||
if (vcpu && !memory) {
|
||||
return {
|
||||
...total,
|
||||
vcpu: total.vcpu + vcpu * replicas,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
vcpu: total.vcpu + vcpu * replicas,
|
||||
memory: total.memory + memory * replicas,
|
||||
};
|
||||
},
|
||||
{ vcpu: 0, memory: 0 } as { vcpu: number; memory: number },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as calculateBillableResources } from './calculateBillableResources';
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'vitest';
|
||||
import getAllocatedResources from './getAllocatedResources';
|
||||
|
||||
test('should return the total number of allocated resources', () => {
|
||||
expect(
|
||||
getAllocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
hasura: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
auth: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
storage: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
}),
|
||||
).toEqual({ vcpu: 0, memory: 2 });
|
||||
|
||||
expect(
|
||||
getAllocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
hasura: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
auth: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
storage: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
).toEqual({ vcpu: 1, memory: 0 });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
|
||||
/**
|
||||
* Returns the allocated resources based on the form values.
|
||||
*
|
||||
* @param formValues - The form values.
|
||||
* @returns The allocated resources.
|
||||
*/
|
||||
export default function getAllocatedResources(
|
||||
formValues: Partial<ResourceSettingsFormValues>,
|
||||
) {
|
||||
return Object.keys(formValues).reduce(
|
||||
({ vcpu, memory }, currentKey) => {
|
||||
// Skip attributes that are not related to any of the services.
|
||||
if (
|
||||
typeof formValues[currentKey] !== 'object' ||
|
||||
!(
|
||||
'vcpu' in formValues[currentKey] && 'memory' in formValues[currentKey]
|
||||
)
|
||||
) {
|
||||
return { vcpu, memory };
|
||||
}
|
||||
|
||||
return {
|
||||
vcpu: vcpu + (formValues[currentKey].vcpu || 0),
|
||||
memory: memory + (formValues[currentKey].memory || 0),
|
||||
};
|
||||
},
|
||||
{
|
||||
vcpu: 0,
|
||||
memory: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as getAllocatedResources } from './getAllocatedResources';
|
||||
@@ -28,6 +28,16 @@ export const MAX_TOTAL_VCPU = 60 * RESOURCE_VCPU_MULTIPLIER;
|
||||
*/
|
||||
export const MAX_TOTAL_MEMORY = MAX_TOTAL_VCPU * RESOURCE_VCPU_MEMORY_RATIO;
|
||||
|
||||
/**
|
||||
* The minimum amount of replicas that has to be allocated per service.
|
||||
*/
|
||||
export const MIN_SERVICE_REPLICAS = 1;
|
||||
|
||||
/**
|
||||
* The maximum amount of replicas that can be allocated per service.
|
||||
*/
|
||||
export const MAX_SERVICE_REPLICAS = 32;
|
||||
|
||||
/**
|
||||
* The minimum amount of CPU that has to be allocated per service.
|
||||
*/
|
||||
@@ -51,58 +61,75 @@ export const MAX_SERVICE_MEMORY =
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
const serviceValidationSchema = Yup.object({
|
||||
replicas: Yup.number()
|
||||
.label('Replicas')
|
||||
.required()
|
||||
.min(1)
|
||||
.max(MAX_SERVICE_REPLICAS)
|
||||
.test(
|
||||
'is-matching-ratio',
|
||||
`vCPU and Memory for this service must match the 1:${RESOURCE_VCPU_MEMORY_RATIO} ratio if more than one replica is selected.`,
|
||||
(replicas: number, { parent }) => {
|
||||
if (replicas === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parent.memory /
|
||||
RESOURCE_MEMORY_MULTIPLIER /
|
||||
(parent.vcpu / RESOURCE_VCPU_MULTIPLIER) ===
|
||||
RESOURCE_VCPU_MEMORY_RATIO
|
||||
);
|
||||
},
|
||||
),
|
||||
vcpu: Yup.number()
|
||||
.label('vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
memory: Yup.number()
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
});
|
||||
|
||||
export const resourceSettingsValidationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
totalAvailableVCPU: Yup.number()
|
||||
.label('Total Available vCPUs')
|
||||
.required()
|
||||
.min(MIN_TOTAL_VCPU)
|
||||
.max(MAX_TOTAL_VCPU),
|
||||
.max(MAX_TOTAL_VCPU)
|
||||
.test(
|
||||
'is-equal-to-services',
|
||||
'Total vCPUs must be equal to the sum of all services.',
|
||||
(totalAvailableVCPU: number, { parent }) =>
|
||||
parent.database.vcpu +
|
||||
parent.hasura.vcpu +
|
||||
parent.auth.vcpu +
|
||||
parent.storage.vcpu ===
|
||||
totalAvailableVCPU,
|
||||
),
|
||||
totalAvailableMemory: Yup.number()
|
||||
.label('Available Memory')
|
||||
.required()
|
||||
.min(MIN_TOTAL_MEMORY)
|
||||
.max(MAX_TOTAL_MEMORY),
|
||||
databaseVCPU: Yup.number()
|
||||
.label('Database vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
databaseMemory: Yup.number()
|
||||
.label('Database Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
hasuraVCPU: Yup.number()
|
||||
.label('Hasura GraphQL vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
hasuraMemory: Yup.number()
|
||||
.label('Hasura GraphQL Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
authVCPU: Yup.number()
|
||||
.label('Auth vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
authMemory: Yup.number()
|
||||
.label('Auth Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
storageVCPU: Yup.number()
|
||||
.label('Storage vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
storageMemory: Yup.number()
|
||||
.label('Storage Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
.max(MAX_TOTAL_MEMORY)
|
||||
.test(
|
||||
'is-equal-to-services',
|
||||
'Total memory must be equal to the sum of all services.',
|
||||
(totalAvailableMemory: number, { parent }) =>
|
||||
parent.database.memory +
|
||||
parent.hasura.memory +
|
||||
parent.auth.memory +
|
||||
parent.storage.memory ===
|
||||
totalAvailableMemory,
|
||||
),
|
||||
database: serviceValidationSchema.required(),
|
||||
hasura: serviceValidationSchema.required(),
|
||||
auth: serviceValidationSchema.required(),
|
||||
storage: serviceValidationSchema.required(),
|
||||
});
|
||||
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
|
||||
@@ -2,7 +2,4 @@ query GetWorkspaceAndProject($workspaceSlug: String!, $projectSlug: String) {
|
||||
workspaces(where: { slug: { _eq: $workspaceSlug } }) {
|
||||
...Workspace
|
||||
}
|
||||
projects: apps(where: { slug: { _eq: $projectSlug } }) {
|
||||
...Project
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
@@ -13,6 +14,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
@@ -21,6 +23,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
storage {
|
||||
@@ -29,6 +32,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from './useProPlan';
|
||||
export { default as useProPlan } from './useProPlan';
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function useProPlan() {
|
||||
},
|
||||
},
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
return { data: data?.plans?.at(0), ...rest };
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { useNhostClient, useUserData } from '@nhost/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export interface UseCurrentWorkspaceAndProjectReturnType {
|
||||
/**
|
||||
@@ -48,14 +49,12 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery(
|
||||
['currentWorkspaceAndProject', workspaceSlug, appSlug],
|
||||
['currentWorkspaceAndProject', workspaceSlug],
|
||||
() =>
|
||||
client.graphql.request<{
|
||||
workspaces: Workspace[];
|
||||
projects?: Project[];
|
||||
}>(GetWorkspaceAndProjectDocument, {
|
||||
workspaceSlug: (workspaceSlug as string) || '',
|
||||
projectSlug: (appSlug as string) || '',
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
@@ -66,6 +65,18 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
},
|
||||
);
|
||||
|
||||
// Return the current workspace and project if using the Nhost backend
|
||||
const [currentWorkspace] = response?.data?.workspaces || [];
|
||||
const currentProject = useMemo(
|
||||
() =>
|
||||
appSlug
|
||||
? currentWorkspace?.projects?.find(
|
||||
(project) => project.slug === appSlug,
|
||||
)
|
||||
: null,
|
||||
[appSlug, currentWorkspace?.projects],
|
||||
);
|
||||
|
||||
// Return a default project if working locally
|
||||
if (!isPlatform) {
|
||||
const localProject: Project = {
|
||||
@@ -117,9 +128,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
};
|
||||
}
|
||||
|
||||
// Return the current workspace and project if using the Nhost backend
|
||||
const [currentWorkspace] = response?.data?.workspaces || [];
|
||||
const [currentProject] = response?.data?.projects || [];
|
||||
const error = Array.isArray(response?.error || {})
|
||||
? response?.error[0]
|
||||
: response?.error;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { Project } from '@/types/application';
|
||||
import type { Project, Workspace } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { NhostSession } from '@nhost/nextjs';
|
||||
import type { NextRouter } from 'next/router';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const mockMatchMediaValue = (query: any) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
|
||||
export const mockRouter: NextRouter = {
|
||||
basePath: '',
|
||||
pathname: '/test-workspace/test-application',
|
||||
@@ -14,7 +24,10 @@ export const mockRouter: NextRouter = {
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {},
|
||||
query: {
|
||||
workspaceSlug: 'test-workspace',
|
||||
appSlug: 'test-application',
|
||||
},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
@@ -67,8 +80,8 @@ export const mockWorkspace: Workspace = {
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
slug: 'test-workspace',
|
||||
members: [],
|
||||
applications: [mockApplication],
|
||||
workspaceMembers: [],
|
||||
projects: [mockApplication],
|
||||
};
|
||||
|
||||
export const mockSession: NhostSession = {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
@@ -51,6 +52,7 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
@@ -59,6 +61,7 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
@@ -67,6 +70,7 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -90,6 +94,7 @@ export const resourcesUpdatedQuery = nhostGraphQLLink.query(
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
@@ -98,6 +103,7 @@ export const resourcesUpdatedQuery = nhostGraphQLLink.query(
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
@@ -106,6 +112,7 @@ export const resourcesUpdatedQuery = nhostGraphQLLink.query(
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
@@ -114,6 +121,7 @@ export const resourcesUpdatedQuery = nhostGraphQLLink.query(
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,8 +11,16 @@ import { ThemeProvider } from '@mui/material/styles';
|
||||
import { NhostClient, NhostProvider } from '@nhost/nextjs';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { Queries, RenderOptions, queries } from '@testing-library/react';
|
||||
import { render as rtlRender } from '@testing-library/react';
|
||||
import type {
|
||||
Queries,
|
||||
RenderOptions,
|
||||
queries,
|
||||
waitForOptions,
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
render as rtlRender,
|
||||
waitForElementToBeRemoved as rtlWaitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
import { RouterContext } from 'next/dist/shared/lib/router-context';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
@@ -87,5 +95,18 @@ function render<
|
||||
});
|
||||
}
|
||||
|
||||
function waitForElementToBeRemoved<T>(
|
||||
callback: T | (() => T),
|
||||
options?: waitForOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
return rtlWaitForElementToBeRemoved(callback, options);
|
||||
} catch {
|
||||
// We shouldn't fail if the element was to be removed but it wasn't there in
|
||||
// the first place.
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { render };
|
||||
export { render, waitForElementToBeRemoved };
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { WorkspaceMembers } from '@/utils/__generated__/graphql';
|
||||
import type { Project } from './application';
|
||||
|
||||
export type Workspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
creatorUserId?: string;
|
||||
members: WorkspaceMembers[];
|
||||
applications: Project[];
|
||||
default?: boolean;
|
||||
};
|
||||
@@ -57,6 +57,11 @@ export const RESOURCE_MEMORY_STEP = 128;
|
||||
*/
|
||||
export const RESOURCE_VCPU_PRICE = 50;
|
||||
|
||||
/**
|
||||
* Price per vCPU and 2 GiB of RAM per minute.
|
||||
*/
|
||||
export const RESOURCE_VCPU_PRICE_PER_MINUTE = 0.0012;
|
||||
|
||||
/**
|
||||
* Maximum number of free projects a user is allowed to have.
|
||||
*/
|
||||
|
||||
156
dashboard/src/utils/__generated__/graphql.ts
generated
156
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -27,6 +27,7 @@ export type Scalars = {
|
||||
citext: any;
|
||||
float64: any;
|
||||
jsonb: any;
|
||||
refresh_token_type: any;
|
||||
smallint: any;
|
||||
timestamp: any;
|
||||
timestamptz: any;
|
||||
@@ -904,6 +905,7 @@ export type ConfigConfig = {
|
||||
functions?: Maybe<ConfigFunctions>;
|
||||
global?: Maybe<ConfigGlobal>;
|
||||
hasura: ConfigHasura;
|
||||
observability?: Maybe<ConfigObservability>;
|
||||
postgres?: Maybe<ConfigPostgres>;
|
||||
provider?: Maybe<ConfigProvider>;
|
||||
storage?: Maybe<ConfigStorage>;
|
||||
@@ -917,6 +919,7 @@ export type ConfigConfigComparisonExp = {
|
||||
functions?: InputMaybe<ConfigFunctionsComparisonExp>;
|
||||
global?: InputMaybe<ConfigGlobalComparisonExp>;
|
||||
hasura?: InputMaybe<ConfigHasuraComparisonExp>;
|
||||
observability?: InputMaybe<ConfigObservabilityComparisonExp>;
|
||||
postgres?: InputMaybe<ConfigPostgresComparisonExp>;
|
||||
provider?: InputMaybe<ConfigProviderComparisonExp>;
|
||||
storage?: InputMaybe<ConfigStorageComparisonExp>;
|
||||
@@ -927,6 +930,7 @@ export type ConfigConfigInsertInput = {
|
||||
functions?: InputMaybe<ConfigFunctionsInsertInput>;
|
||||
global?: InputMaybe<ConfigGlobalInsertInput>;
|
||||
hasura: ConfigHasuraInsertInput;
|
||||
observability?: InputMaybe<ConfigObservabilityInsertInput>;
|
||||
postgres?: InputMaybe<ConfigPostgresInsertInput>;
|
||||
provider?: InputMaybe<ConfigProviderInsertInput>;
|
||||
storage?: InputMaybe<ConfigStorageInsertInput>;
|
||||
@@ -937,6 +941,7 @@ export type ConfigConfigUpdateInput = {
|
||||
functions?: InputMaybe<ConfigFunctionsUpdateInput>;
|
||||
global?: InputMaybe<ConfigGlobalUpdateInput>;
|
||||
hasura?: InputMaybe<ConfigHasuraUpdateInput>;
|
||||
observability?: InputMaybe<ConfigObservabilityUpdateInput>;
|
||||
postgres?: InputMaybe<ConfigPostgresUpdateInput>;
|
||||
provider?: InputMaybe<ConfigProviderUpdateInput>;
|
||||
storage?: InputMaybe<ConfigStorageUpdateInput>;
|
||||
@@ -1033,6 +1038,26 @@ export type ConfigGlobalUpdateInput = {
|
||||
environment?: InputMaybe<Array<ConfigEnvironmentVariableUpdateInput>>;
|
||||
};
|
||||
|
||||
export type ConfigGrafana = {
|
||||
__typename?: 'ConfigGrafana';
|
||||
adminPassword: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ConfigGrafanaComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigGrafanaComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigGrafanaComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigGrafanaComparisonExp>>;
|
||||
adminPassword?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigGrafanaInsertInput = {
|
||||
adminPassword: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ConfigGrafanaUpdateInput = {
|
||||
adminPassword?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigHasura = {
|
||||
__typename?: 'ConfigHasura';
|
||||
adminSecret: Scalars['String'];
|
||||
@@ -1222,6 +1247,26 @@ export type ConfigLocaleComparisonExp = {
|
||||
_nin?: InputMaybe<Array<Scalars['ConfigLocale']>>;
|
||||
};
|
||||
|
||||
export type ConfigObservability = {
|
||||
__typename?: 'ConfigObservability';
|
||||
grafana?: Maybe<ConfigGrafana>;
|
||||
};
|
||||
|
||||
export type ConfigObservabilityComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigObservabilityComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigObservabilityComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigObservabilityComparisonExp>>;
|
||||
grafana?: InputMaybe<ConfigGrafanaComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigObservabilityInsertInput = {
|
||||
grafana?: InputMaybe<ConfigGrafanaInsertInput>;
|
||||
};
|
||||
|
||||
export type ConfigObservabilityUpdateInput = {
|
||||
grafana?: InputMaybe<ConfigGrafanaUpdateInput>;
|
||||
};
|
||||
|
||||
export type ConfigPortComparisonExp = {
|
||||
_eq?: InputMaybe<Scalars['ConfigPort']>;
|
||||
_in?: InputMaybe<Array<Scalars['ConfigPort']>>;
|
||||
@@ -3490,14 +3535,22 @@ export type AuthRefreshTokens = {
|
||||
__typename?: 'authRefreshTokens';
|
||||
createdAt: Scalars['timestamptz'];
|
||||
expiresAt: Scalars['timestamptz'];
|
||||
metadata?: Maybe<Scalars['jsonb']>;
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken: Scalars['uuid'];
|
||||
refreshTokenHash?: Maybe<Scalars['String']>;
|
||||
type: Scalars['refresh_token_type'];
|
||||
/** An object relationship */
|
||||
user: Users;
|
||||
userId: Scalars['uuid'];
|
||||
};
|
||||
|
||||
|
||||
/** User refresh tokens. Hasura auth uses them to rotate new access tokens as long as the refresh token is not expired. Don't modify its structure as Hasura Auth relies on it to function properly. */
|
||||
export type AuthRefreshTokensMetadataArgs = {
|
||||
path?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** aggregated selection of "auth.refresh_tokens" */
|
||||
export type AuthRefreshTokens_Aggregate = {
|
||||
__typename?: 'authRefreshTokens_aggregate';
|
||||
@@ -3538,6 +3591,11 @@ export type AuthRefreshTokens_Aggregate_Order_By = {
|
||||
min?: InputMaybe<AuthRefreshTokens_Min_Order_By>;
|
||||
};
|
||||
|
||||
/** append existing jsonb value of filtered columns with new jsonb value */
|
||||
export type AuthRefreshTokens_Append_Input = {
|
||||
metadata?: InputMaybe<Scalars['jsonb']>;
|
||||
};
|
||||
|
||||
/** input type for inserting array relation for remote table "auth.refresh_tokens" */
|
||||
export type AuthRefreshTokens_Arr_Rel_Insert_Input = {
|
||||
data: Array<AuthRefreshTokens_Insert_Input>;
|
||||
@@ -3552,8 +3610,10 @@ export type AuthRefreshTokens_Bool_Exp = {
|
||||
_or?: InputMaybe<Array<AuthRefreshTokens_Bool_Exp>>;
|
||||
createdAt?: InputMaybe<Timestamptz_Comparison_Exp>;
|
||||
expiresAt?: InputMaybe<Timestamptz_Comparison_Exp>;
|
||||
metadata?: InputMaybe<Jsonb_Comparison_Exp>;
|
||||
refreshToken?: InputMaybe<Uuid_Comparison_Exp>;
|
||||
refreshTokenHash?: InputMaybe<String_Comparison_Exp>;
|
||||
type?: InputMaybe<Refresh_Token_Type_Comparison_Exp>;
|
||||
user?: InputMaybe<Users_Bool_Exp>;
|
||||
userId?: InputMaybe<Uuid_Comparison_Exp>;
|
||||
};
|
||||
@@ -3564,12 +3624,29 @@ export enum AuthRefreshTokens_Constraint {
|
||||
RefreshTokensPkey = 'refresh_tokens_pkey'
|
||||
}
|
||||
|
||||
/** delete the field or element with specified path (for JSON arrays, negative integers count from the end) */
|
||||
export type AuthRefreshTokens_Delete_At_Path_Input = {
|
||||
metadata?: InputMaybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
/** delete the array element with specified index (negative integers count from the end). throws an error if top level container is not an array */
|
||||
export type AuthRefreshTokens_Delete_Elem_Input = {
|
||||
metadata?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
/** delete key/value pair or string element. key/value pairs are matched based on their key value */
|
||||
export type AuthRefreshTokens_Delete_Key_Input = {
|
||||
metadata?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** input type for inserting data into table "auth.refresh_tokens" */
|
||||
export type AuthRefreshTokens_Insert_Input = {
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
expiresAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
metadata?: InputMaybe<Scalars['jsonb']>;
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: InputMaybe<Scalars['uuid']>;
|
||||
type?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
user?: InputMaybe<Users_Obj_Rel_Insert_Input>;
|
||||
userId?: InputMaybe<Scalars['uuid']>;
|
||||
};
|
||||
@@ -3582,6 +3659,7 @@ export type AuthRefreshTokens_Max_Fields = {
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: Maybe<Scalars['uuid']>;
|
||||
refreshTokenHash?: Maybe<Scalars['String']>;
|
||||
type?: Maybe<Scalars['refresh_token_type']>;
|
||||
userId?: Maybe<Scalars['uuid']>;
|
||||
};
|
||||
|
||||
@@ -3592,6 +3670,7 @@ export type AuthRefreshTokens_Max_Order_By = {
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: InputMaybe<Order_By>;
|
||||
refreshTokenHash?: InputMaybe<Order_By>;
|
||||
type?: InputMaybe<Order_By>;
|
||||
userId?: InputMaybe<Order_By>;
|
||||
};
|
||||
|
||||
@@ -3603,6 +3682,7 @@ export type AuthRefreshTokens_Min_Fields = {
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: Maybe<Scalars['uuid']>;
|
||||
refreshTokenHash?: Maybe<Scalars['String']>;
|
||||
type?: Maybe<Scalars['refresh_token_type']>;
|
||||
userId?: Maybe<Scalars['uuid']>;
|
||||
};
|
||||
|
||||
@@ -3613,6 +3693,7 @@ export type AuthRefreshTokens_Min_Order_By = {
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: InputMaybe<Order_By>;
|
||||
refreshTokenHash?: InputMaybe<Order_By>;
|
||||
type?: InputMaybe<Order_By>;
|
||||
userId?: InputMaybe<Order_By>;
|
||||
};
|
||||
|
||||
@@ -3636,8 +3717,10 @@ export type AuthRefreshTokens_On_Conflict = {
|
||||
export type AuthRefreshTokens_Order_By = {
|
||||
createdAt?: InputMaybe<Order_By>;
|
||||
expiresAt?: InputMaybe<Order_By>;
|
||||
metadata?: InputMaybe<Order_By>;
|
||||
refreshToken?: InputMaybe<Order_By>;
|
||||
refreshTokenHash?: InputMaybe<Order_By>;
|
||||
type?: InputMaybe<Order_By>;
|
||||
user?: InputMaybe<Users_Order_By>;
|
||||
userId?: InputMaybe<Order_By>;
|
||||
};
|
||||
@@ -3648,6 +3731,11 @@ export type AuthRefreshTokens_Pk_Columns_Input = {
|
||||
refreshToken: Scalars['uuid'];
|
||||
};
|
||||
|
||||
/** prepend existing jsonb value of filtered columns with new jsonb value */
|
||||
export type AuthRefreshTokens_Prepend_Input = {
|
||||
metadata?: InputMaybe<Scalars['jsonb']>;
|
||||
};
|
||||
|
||||
/** select columns of table "auth.refresh_tokens" */
|
||||
export enum AuthRefreshTokens_Select_Column {
|
||||
/** column name */
|
||||
@@ -3655,10 +3743,14 @@ export enum AuthRefreshTokens_Select_Column {
|
||||
/** column name */
|
||||
ExpiresAt = 'expiresAt',
|
||||
/** column name */
|
||||
Metadata = 'metadata',
|
||||
/** column name */
|
||||
RefreshToken = 'refreshToken',
|
||||
/** column name */
|
||||
RefreshTokenHash = 'refreshTokenHash',
|
||||
/** column name */
|
||||
Type = 'type',
|
||||
/** column name */
|
||||
UserId = 'userId'
|
||||
}
|
||||
|
||||
@@ -3666,8 +3758,10 @@ export enum AuthRefreshTokens_Select_Column {
|
||||
export type AuthRefreshTokens_Set_Input = {
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
expiresAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
metadata?: InputMaybe<Scalars['jsonb']>;
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: InputMaybe<Scalars['uuid']>;
|
||||
type?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
userId?: InputMaybe<Scalars['uuid']>;
|
||||
};
|
||||
|
||||
@@ -3683,9 +3777,11 @@ export type AuthRefreshTokens_Stream_Cursor_Input = {
|
||||
export type AuthRefreshTokens_Stream_Cursor_Value_Input = {
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
expiresAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
metadata?: InputMaybe<Scalars['jsonb']>;
|
||||
/** DEPRECATED: auto-generated refresh token id. Will be replaced by a genereric id column that will be used as a primary key, not the refresh token itself. Use refresh_token_hash instead. */
|
||||
refreshToken?: InputMaybe<Scalars['uuid']>;
|
||||
refreshTokenHash?: InputMaybe<Scalars['String']>;
|
||||
type?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
userId?: InputMaybe<Scalars['uuid']>;
|
||||
};
|
||||
|
||||
@@ -3696,12 +3792,26 @@ export enum AuthRefreshTokens_Update_Column {
|
||||
/** column name */
|
||||
ExpiresAt = 'expiresAt',
|
||||
/** column name */
|
||||
Metadata = 'metadata',
|
||||
/** column name */
|
||||
RefreshToken = 'refreshToken',
|
||||
/** column name */
|
||||
Type = 'type',
|
||||
/** column name */
|
||||
UserId = 'userId'
|
||||
}
|
||||
|
||||
export type AuthRefreshTokens_Updates = {
|
||||
/** append existing jsonb value of filtered columns with new jsonb value */
|
||||
_append?: InputMaybe<AuthRefreshTokens_Append_Input>;
|
||||
/** delete the field or element with specified path (for JSON arrays, negative integers count from the end) */
|
||||
_delete_at_path?: InputMaybe<AuthRefreshTokens_Delete_At_Path_Input>;
|
||||
/** delete the array element with specified index (negative integers count from the end). throws an error if top level container is not an array */
|
||||
_delete_elem?: InputMaybe<AuthRefreshTokens_Delete_Elem_Input>;
|
||||
/** delete key/value pair or string element. key/value pairs are matched based on their key value */
|
||||
_delete_key?: InputMaybe<AuthRefreshTokens_Delete_Key_Input>;
|
||||
/** prepend existing jsonb value of filtered columns with new jsonb value */
|
||||
_prepend?: InputMaybe<AuthRefreshTokens_Prepend_Input>;
|
||||
/** sets the columns of the filtered rows to the given values */
|
||||
_set?: InputMaybe<AuthRefreshTokens_Set_Input>;
|
||||
where: AuthRefreshTokens_Bool_Exp;
|
||||
@@ -11168,6 +11278,11 @@ export type Mutation_RootUpdateAuthProvidersArgs = {
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootUpdateAuthRefreshTokenArgs = {
|
||||
_append?: InputMaybe<AuthRefreshTokens_Append_Input>;
|
||||
_delete_at_path?: InputMaybe<AuthRefreshTokens_Delete_At_Path_Input>;
|
||||
_delete_elem?: InputMaybe<AuthRefreshTokens_Delete_Elem_Input>;
|
||||
_delete_key?: InputMaybe<AuthRefreshTokens_Delete_Key_Input>;
|
||||
_prepend?: InputMaybe<AuthRefreshTokens_Prepend_Input>;
|
||||
_set?: InputMaybe<AuthRefreshTokens_Set_Input>;
|
||||
pk_columns: AuthRefreshTokens_Pk_Columns_Input;
|
||||
};
|
||||
@@ -11175,6 +11290,11 @@ export type Mutation_RootUpdateAuthRefreshTokenArgs = {
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootUpdateAuthRefreshTokensArgs = {
|
||||
_append?: InputMaybe<AuthRefreshTokens_Append_Input>;
|
||||
_delete_at_path?: InputMaybe<AuthRefreshTokens_Delete_At_Path_Input>;
|
||||
_delete_elem?: InputMaybe<AuthRefreshTokens_Delete_Elem_Input>;
|
||||
_delete_key?: InputMaybe<AuthRefreshTokens_Delete_Key_Input>;
|
||||
_prepend?: InputMaybe<AuthRefreshTokens_Prepend_Input>;
|
||||
_set?: InputMaybe<AuthRefreshTokens_Set_Input>;
|
||||
where: AuthRefreshTokens_Bool_Exp;
|
||||
};
|
||||
@@ -12971,7 +13091,6 @@ export type Query_Root = {
|
||||
/** fetch aggregated fields from the table: "cli_tokens" */
|
||||
cliTokensAggregate: CliTokens_Aggregate;
|
||||
config?: Maybe<ConfigConfig>;
|
||||
configRawJSON: Scalars['String'];
|
||||
configs: Array<ConfigAppConfig>;
|
||||
/** fetch data from the table: "continents" */
|
||||
continents: Array<Continents>;
|
||||
@@ -13495,12 +13614,6 @@ export type Query_RootConfigArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootConfigRawJsonArgs = {
|
||||
appID: Scalars['uuid'];
|
||||
resolve: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootConfigsArgs = {
|
||||
resolve: Scalars['Boolean'];
|
||||
where?: InputMaybe<ConfigConfigComparisonExp>;
|
||||
@@ -13946,6 +14059,19 @@ export type Query_RootWorkspacesAggregateArgs = {
|
||||
where?: InputMaybe<Workspaces_Bool_Exp>;
|
||||
};
|
||||
|
||||
/** Boolean expression to compare columns of type "refresh_token_type". All fields are combined with logical 'AND'. */
|
||||
export type Refresh_Token_Type_Comparison_Exp = {
|
||||
_eq?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
_gt?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
_gte?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
_in?: InputMaybe<Array<Scalars['refresh_token_type']>>;
|
||||
_is_null?: InputMaybe<Scalars['Boolean']>;
|
||||
_lt?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
_lte?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
_neq?: InputMaybe<Scalars['refresh_token_type']>;
|
||||
_nin?: InputMaybe<Array<Scalars['refresh_token_type']>>;
|
||||
};
|
||||
|
||||
/** columns and relationships of "regions" */
|
||||
export type Regions = {
|
||||
__typename?: 'regions';
|
||||
@@ -17706,7 +17832,7 @@ export type GetWorkspaceAndProjectQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetWorkspaceAndProjectQuery = { __typename?: 'query_root', workspaces: Array<{ __typename?: 'workspaces', id: any, name: string, slug: string, creatorUserId?: any | null, workspaceMembers: Array<{ __typename?: 'workspaceMembers', id: any, type: string, user: { __typename?: 'users', id: any, email?: any | null, displayName: string } }>, projects: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, price: number, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> }>, projects: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, price: number, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> };
|
||||
export type GetWorkspaceAndProjectQuery = { __typename?: 'query_root', workspaces: Array<{ __typename?: 'workspaces', id: any, name: string, slug: string, creatorUserId?: any | null, workspaceMembers: Array<{ __typename?: 'workspaceMembers', id: any, type: string, user: { __typename?: 'users', id: any, email?: any | null, displayName: string } }>, projects: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, price: number, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> }> };
|
||||
|
||||
export type InsertApplicationMutationVariables = Exact<{
|
||||
app: Apps_Insert_Input;
|
||||
@@ -17751,14 +17877,14 @@ export type GetEnvironmentVariablesQueryVariables = Exact<{
|
||||
|
||||
export type GetEnvironmentVariablesQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', global?: { __typename?: 'ConfigGlobal', environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string, id: string }> | null } | null, hasura: { __typename?: 'ConfigHasura', adminSecret: string, webhookSecret: string, jwtSecrets?: Array<{ __typename?: 'ConfigJWTSecret', issuer?: string | null, key?: string | null, type?: string | null, jwk_url?: any | null, header?: string | null, claims_namespace_path?: string | null, claims_namespace?: string | null, claims_format?: string | null, audience?: string | null, allowed_skew?: any | null }> | null } } | null };
|
||||
|
||||
export type ServiceResourcesFragment = { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null };
|
||||
export type ServiceResourcesFragment = { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null };
|
||||
|
||||
export type GetResourcesQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetResourcesQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null } | null };
|
||||
export type GetResourcesQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas: any, compute: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } } | null } | null } | null };
|
||||
|
||||
export type PermissionVariableFragment = { __typename?: 'ConfigAuthsessionaccessTokenCustomClaims', key: string, value: string, id: string };
|
||||
|
||||
@@ -18257,6 +18383,7 @@ export const ServiceResourcesFragmentDoc = gql`
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
@@ -18265,6 +18392,7 @@ export const ServiceResourcesFragmentDoc = gql`
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
@@ -18273,6 +18401,7 @@ export const ServiceResourcesFragmentDoc = gql`
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
storage {
|
||||
@@ -18281,6 +18410,7 @@ export const ServiceResourcesFragmentDoc = gql`
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18827,12 +18957,8 @@ export const GetWorkspaceAndProjectDocument = gql`
|
||||
workspaces(where: {slug: {_eq: $workspaceSlug}}) {
|
||||
...Workspace
|
||||
}
|
||||
projects: apps(where: {slug: {_eq: $projectSlug}}) {
|
||||
...Project
|
||||
}
|
||||
}
|
||||
${WorkspaceFragmentDoc}
|
||||
${ProjectFragmentDoc}`;
|
||||
${WorkspaceFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useGetWorkspaceAndProjectQuery__
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import features from '@/data/features.json';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getLocalBackendUrl } from '@/utils/env';
|
||||
import slugify from 'slugify';
|
||||
import type { DeploymentRowFragment } from './__generated__/graphql';
|
||||
|
||||
@@ -55,22 +54,6 @@ export function getCurrentEnvironment(): Environment {
|
||||
return (process.env.NEXT_PUBLIC_ENV || 'dev') as Environment;
|
||||
}
|
||||
|
||||
export function generateRemoteAppUrl(subdomain: string): string {
|
||||
if (process.env.NEXT_PUBLIC_NHOST_PLATFORM !== 'true') {
|
||||
return getLocalBackendUrl();
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return process.env.NEXT_PUBLIC_NHOST_BACKEND_URL;
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'staging') {
|
||||
return `https://${subdomain}.staging.nhost.run`;
|
||||
}
|
||||
|
||||
return `https://${subdomain}.nhost.run`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the state number of the application to its string equivalent.
|
||||
* @param appStatus The current state of the application.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { test } from 'vitest';
|
||||
import getUnallocatedResources from './getUnallocatedResources';
|
||||
|
||||
test('should return 0 for CPU and Memory if all the available resources are allocated', () => {
|
||||
expect(
|
||||
getUnallocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 4,
|
||||
totalAvailableMemory: 8,
|
||||
databaseVCPU: 1,
|
||||
databaseMemory: 2,
|
||||
hasuraVCPU: 1,
|
||||
hasuraMemory: 2,
|
||||
authVCPU: 1,
|
||||
authMemory: 2,
|
||||
storageVCPU: 1,
|
||||
storageMemory: 2,
|
||||
}),
|
||||
).toEqual({ vcpu: 0, memory: 0 });
|
||||
});
|
||||
|
||||
test('should return the unallocated resources if not everything is allocated', () => {
|
||||
expect(
|
||||
getUnallocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
databaseVCPU: 0,
|
||||
databaseMemory: 0.5,
|
||||
hasuraVCPU: 0,
|
||||
hasuraMemory: 0.5,
|
||||
authVCPU: 0,
|
||||
authMemory: 0.5,
|
||||
storageVCPU: 0,
|
||||
storageMemory: 0.5,
|
||||
}),
|
||||
).toEqual({ vcpu: 1, memory: 0 });
|
||||
|
||||
expect(
|
||||
getUnallocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
databaseVCPU: 0.25,
|
||||
databaseMemory: 0,
|
||||
hasuraVCPU: 0.25,
|
||||
hasuraMemory: 0,
|
||||
authVCPU: 0.25,
|
||||
authMemory: 0,
|
||||
storageVCPU: 0.25,
|
||||
storageMemory: 0,
|
||||
}),
|
||||
).toEqual({ vcpu: 0, memory: 2 });
|
||||
});
|
||||
|
||||
test('should return negative values if services are overallocated', () => {
|
||||
expect(
|
||||
getUnallocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
databaseVCPU: 0.5,
|
||||
databaseMemory: 0.5,
|
||||
hasuraVCPU: 0.5,
|
||||
hasuraMemory: 0.5,
|
||||
authVCPU: 0.5,
|
||||
authMemory: 0.5,
|
||||
storageVCPU: 0.5,
|
||||
storageMemory: 0.5,
|
||||
}),
|
||||
).toEqual({ vcpu: -1, memory: 0 });
|
||||
|
||||
expect(
|
||||
getUnallocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
databaseVCPU: 0.25,
|
||||
databaseMemory: 1,
|
||||
hasuraVCPU: 0.25,
|
||||
hasuraMemory: 1,
|
||||
authVCPU: 0.25,
|
||||
authMemory: 1,
|
||||
storageVCPU: 0.25,
|
||||
storageMemory: 1,
|
||||
}),
|
||||
).toEqual({ vcpu: 0, memory: -2 });
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
|
||||
/**
|
||||
* Returns the unallocated resources based on the form values.
|
||||
*
|
||||
* @param formValues - The form values.
|
||||
* @returns The unallocated resources. Negative values mean that the resources
|
||||
* are overallocated.
|
||||
*/
|
||||
export default function getUnallocatedResources(
|
||||
formValues: Partial<ResourceSettingsFormValues>,
|
||||
) {
|
||||
const allocatedVCPU =
|
||||
formValues.databaseVCPU +
|
||||
formValues.hasuraVCPU +
|
||||
formValues.authVCPU +
|
||||
formValues.storageVCPU;
|
||||
|
||||
const allocatedMemory =
|
||||
formValues.databaseMemory +
|
||||
formValues.hasuraMemory +
|
||||
formValues.authMemory +
|
||||
formValues.storageMemory;
|
||||
|
||||
return {
|
||||
vcpu: formValues.totalAvailableVCPU - allocatedVCPU,
|
||||
memory: formValues.totalAvailableMemory - allocatedMemory,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './getUnallocatedResources';
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
// @ts-ignore
|
||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] }), react()],
|
||||
test: {
|
||||
testTimeout: 5000,
|
||||
testTimeout: 20000,
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: 'src/setupTests.ts',
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d72ae3f3: Add section on Service Replicas
|
||||
|
||||
## 0.0.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -36,3 +36,5 @@ To configure dedicated compute to your projects, all you have to do is navigate
|
||||
|
||||
|
||||

|
||||
|
||||
To further improve availability and fault tolerance, you can also leverage Service Replicas. To learn more, check out the documentation for [Service Replicas](https://docs.nhost.io/platform/service-replicas).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Git'
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
image: /img/og/platform/github-integration.png
|
||||
---
|
||||
|
||||
|
||||
57
docs/docs/platform/service-replicas.mdx
Normal file
57
docs/docs/platform/service-replicas.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: 'Service Replicas'
|
||||
sidebar_position: 1
|
||||
image: /img/og/platform/service-replicas.png
|
||||
---
|
||||
|
||||
Service Replicas is a feature that allows you to create multiple replicas of your services, enhancing availability and fault tolerance for your Nhost apps. By distributing user requests among replicas, your apps can handle more traffic and provide a better user experience. This documentation touches on the key aspects of Service Replicas in the context of the Nhost Cloud Platform.
|
||||
|
||||

|
||||
|
||||
To read the announcement, check out our [blog post](https://nhost.io/blog/service-replicas).
|
||||
|
||||
## Supported Services
|
||||
|
||||
Replicas can be configured for the following services:
|
||||
|
||||
- Hasura
|
||||
- Auth
|
||||
- Storage
|
||||
|
||||
Currently, we don't support replicas for Postgres.
|
||||
|
||||
## Configuring Service Replicas
|
||||
|
||||
To configure Service Replicas for your project, follow these steps:
|
||||
|
||||
1. Navigate to your project's settings in the Nhost Dashboard.
|
||||
2. Click on "Compute Resources".
|
||||
3. Locate the "Replicas" slider for each service (Hasura, Auth, and Storage).
|
||||
4. Adjust the number of replicas for each service as needed.
|
||||
5. Save your changes.
|
||||
|
||||
Please note that when setting multiple replicas for a service, the 1:2 ratio between CPU and RAM for that service has to be respected. With only one replica, this ratio does not need to be respected at the service level.
|
||||
|
||||
## Benefits
|
||||
|
||||
- Improved fault tolerance: Multiple replicas ensure that if one instance crashes duo to an unexpected issue, the other replicas can continue to serve user requests.
|
||||
- Improved availability: Distributing user requests among multiple replicas allows your apps to handle more traffic and maintain a high level of performance.
|
||||
- Load balancing: Distributing workloads evenly among replicas to prevent bottlenecks and ensure smooth performance during peak times.
|
||||
|
||||
## Pricing
|
||||
|
||||
Pricing is based on the size of each replica and the total number of replicas you have configured for each service:
|
||||
|
||||
- If you allocate 1 vCPU and 2 GiB of RAM to the auth service, for example, the cost for a single replica is $50.
|
||||
- If you configure 3 replicas for auth, the total cost for all replicas would be $150 (3 replicas x $50 per replica).
|
||||
|
||||
Please note that Service Replicas is available only for projects on the Pro plan or higher.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Postgres replication: As mentioned earlier, we do not have support for multiple replicas of Postgres. This feature may be added in the future.
|
||||
- Resource ratio: When configuring multiple replicas for a service, you must adhere to the 1:2 ratio between CPU and RAM for that service.
|
||||
|
||||

|
||||
|
||||
For more information on compute resources and scaling your apps, refer to our documentation on [Compute Resources](https://docs.nhost.io/platform/compute).
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.0.16",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
BIN
docs/static/img/platform/service-replicas/replicas-diagram.png
vendored
Normal file
BIN
docs/static/img/platform/service-replicas/replicas-diagram.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 475 KiB |
177
pnpm-lock.yaml
generated
177
pnpm-lock.yaml
generated
@@ -200,7 +200,7 @@ importers:
|
||||
validator: ^13.7.0
|
||||
vite: ^4.0.2
|
||||
vite-tsconfig-paths: ^4.0.3
|
||||
vitest: ^0.30.0
|
||||
vitest: ^0.30.1
|
||||
webpack: ^5.75.0
|
||||
yup: ^1.0.2
|
||||
yup-password: ^0.2.2
|
||||
@@ -299,7 +299,7 @@ importers:
|
||||
'@typescript-eslint/eslint-plugin': 5.43.0_ivdjtymx6ubvknadox4oh4qsue
|
||||
'@typescript-eslint/parser': 5.43.0_eslint@8.28.0
|
||||
'@vitejs/plugin-react': 4.0.0_vite@4.0.4
|
||||
'@vitest/coverage-c8': 0.30.0_vitest@0.30.0
|
||||
'@vitest/coverage-c8': 0.30.0_vitest@0.30.1
|
||||
autoprefixer: 10.4.13_postcss@8.4.19
|
||||
babel-loader: 8.3.0_npabyccmuonwo2rku4k53xo3hi
|
||||
babel-plugin-transform-remove-console: 6.9.4
|
||||
@@ -333,7 +333,7 @@ importers:
|
||||
tsconfig-paths-webpack-plugin: 4.0.0
|
||||
vite: 4.0.4_@types+node@16.18.11
|
||||
vite-tsconfig-paths: 4.0.3_vite@4.0.4
|
||||
vitest: 0.30.0_jsdom@21.0.0
|
||||
vitest: 0.30.1_jsdom@21.0.0
|
||||
webpack: 5.75.0
|
||||
|
||||
docs:
|
||||
@@ -5619,7 +5619,6 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
dev: true
|
||||
|
||||
/@date-io/core/2.16.0:
|
||||
resolution: {integrity: sha512-DYmSzkr+jToahwWrsiRA2/pzMEtz9Bq1euJwoOuYwuwIYXnZFtHajY2E6a1VNVDc9jP8YUXK1BvnZH9mmT19Zg==}
|
||||
@@ -9470,7 +9469,7 @@ packages:
|
||||
dependencies:
|
||||
'@types/istanbul-lib-coverage': 2.0.4
|
||||
'@types/istanbul-reports': 3.0.1
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
'@types/yargs': 15.0.14
|
||||
chalk: 4.1.2
|
||||
dev: true
|
||||
@@ -9481,7 +9480,7 @@ packages:
|
||||
dependencies:
|
||||
'@types/istanbul-lib-coverage': 2.0.4
|
||||
'@types/istanbul-reports': 3.0.1
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
'@types/yargs': 16.0.4
|
||||
chalk: 4.1.2
|
||||
dev: true
|
||||
@@ -9493,7 +9492,7 @@ packages:
|
||||
'@jest/schemas': 29.0.0
|
||||
'@types/istanbul-lib-coverage': 2.0.4
|
||||
'@types/istanbul-reports': 3.0.1
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
'@types/yargs': 17.0.13
|
||||
chalk: 4.1.2
|
||||
|
||||
@@ -9529,6 +9528,10 @@ packages:
|
||||
/@jridgewell/sourcemap-codec/1.4.14:
|
||||
resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
|
||||
|
||||
/@jridgewell/sourcemap-codec/1.4.15:
|
||||
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
|
||||
dev: true
|
||||
|
||||
/@jridgewell/trace-mapping/0.3.17:
|
||||
resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==}
|
||||
dependencies:
|
||||
@@ -9540,7 +9543,6 @@ packages:
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.0
|
||||
'@jridgewell/sourcemap-codec': 1.4.14
|
||||
dev: true
|
||||
|
||||
/@leichtgewicht/ip-codec/2.0.3:
|
||||
resolution: {integrity: sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==}
|
||||
@@ -13041,7 +13043,7 @@ packages:
|
||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1'
|
||||
dependencies:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.2.1_postcss@8.4.20
|
||||
tailwindcss: 3.2.1_v776zzvn44o7tpgzieipaairwm
|
||||
|
||||
/@tailwindcss/forms/0.5.3_tailwindcss@3.2.4:
|
||||
resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
|
||||
@@ -13250,19 +13252,15 @@ packages:
|
||||
|
||||
/@tsconfig/node10/1.0.8:
|
||||
resolution: {integrity: sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==}
|
||||
dev: true
|
||||
|
||||
/@tsconfig/node12/1.0.9:
|
||||
resolution: {integrity: sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==}
|
||||
dev: true
|
||||
|
||||
/@tsconfig/node14/1.0.1:
|
||||
resolution: {integrity: sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==}
|
||||
dev: true
|
||||
|
||||
/@tsconfig/node16/1.0.2:
|
||||
resolution: {integrity: sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==}
|
||||
dev: true
|
||||
|
||||
/@types/argparse/1.0.38:
|
||||
resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
|
||||
@@ -13367,7 +13365,7 @@ packages:
|
||||
resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==}
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
dev: true
|
||||
|
||||
/@types/glob/8.1.0:
|
||||
@@ -13380,7 +13378,7 @@ packages:
|
||||
/@types/graceful-fs/4.1.5:
|
||||
resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
dev: true
|
||||
|
||||
/@types/hast/2.3.4:
|
||||
@@ -13527,7 +13525,7 @@ packages:
|
||||
/@types/node-fetch/2.6.2:
|
||||
resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
form-data: 3.0.1
|
||||
dev: true
|
||||
|
||||
@@ -13774,7 +13772,7 @@ packages:
|
||||
/@types/webpack-sources/3.2.0:
|
||||
resolution: {integrity: sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
'@types/source-list-map': 0.1.2
|
||||
source-map: 0.7.4
|
||||
dev: true
|
||||
@@ -13782,7 +13780,7 @@ packages:
|
||||
/@types/webpack/4.41.33:
|
||||
resolution: {integrity: sha512-PPajH64Ft2vWevkerISMtnZ8rTs4YmRbs+23c402J0INmxDKCrhZNvwZYtzx96gY2wAtXdrK1BS2fiC8MlLr3g==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
'@types/tapable': 1.0.8
|
||||
'@types/uglify-js': 3.17.1
|
||||
'@types/webpack-sources': 3.2.0
|
||||
@@ -14542,6 +14540,17 @@ packages:
|
||||
vitest: 0.30.0
|
||||
dev: true
|
||||
|
||||
/@vitest/coverage-c8/0.30.0_vitest@0.30.1:
|
||||
resolution: {integrity: sha512-NC0GgT4hUyPYUJHE9Sx9gU41pbYWBFI+3nUuZ/ZiQU8WQy27xQJSYGkDmB6wFeAyS5IxUXqhCRsBOK9fEvgmtA==}
|
||||
peerDependencies:
|
||||
vitest: '>=0.30.0 <1'
|
||||
dependencies:
|
||||
c8: 7.13.0
|
||||
picocolors: 1.0.0
|
||||
std-env: 3.3.2
|
||||
vitest: 0.30.1_jsdom@21.0.0
|
||||
dev: true
|
||||
|
||||
/@vitest/expect/0.30.0:
|
||||
resolution: {integrity: sha512-b/jLWBqi6WQHfezWm8VjgXdIyfejAurtxqdyCdDqoToCim5W/nDxKjFAADitEHPz80oz+IP+c+wmkGKBucSpiw==}
|
||||
dependencies:
|
||||
@@ -14550,6 +14559,14 @@ packages:
|
||||
chai: 4.3.7
|
||||
dev: true
|
||||
|
||||
/@vitest/expect/0.30.1:
|
||||
resolution: {integrity: sha512-c3kbEtN8XXJSeN81iDGq29bUzSjQhjES2WR3aColsS4lPGbivwLtas4DNUe0jD9gg/FYGIteqOenfU95EFituw==}
|
||||
dependencies:
|
||||
'@vitest/spy': 0.30.1
|
||||
'@vitest/utils': 0.30.1
|
||||
chai: 4.3.7
|
||||
dev: true
|
||||
|
||||
/@vitest/runner/0.30.0:
|
||||
resolution: {integrity: sha512-Xh4xkdRcymdeRNrSwjhgarCTSgnQu2J59wsFI6i4UhKrL5whzo5+vWyq7iWK1ht3fppPeNAtvkbqUDf+OJSCbQ==}
|
||||
dependencies:
|
||||
@@ -14559,6 +14576,15 @@ packages:
|
||||
pathe: 1.1.0
|
||||
dev: true
|
||||
|
||||
/@vitest/runner/0.30.1:
|
||||
resolution: {integrity: sha512-W62kT/8i0TF1UBCNMRtRMOBWJKRnNyv9RrjIgdUryEe0wNpGZvvwPDLuzYdxvgSckzjp54DSpv1xUbv4BQ0qVA==}
|
||||
dependencies:
|
||||
'@vitest/utils': 0.30.1
|
||||
concordance: 5.0.4
|
||||
p-limit: 4.0.0
|
||||
pathe: 1.1.0
|
||||
dev: true
|
||||
|
||||
/@vitest/snapshot/0.30.0:
|
||||
resolution: {integrity: sha512-e4eSGCy36Bw3/Tkir9qYJDlFsUz3NALFPNJSxzlY8CFl901TV9iZdKgpqXpyG1sAhLO0tPHThBAMHRi8hRA8cg==}
|
||||
dependencies:
|
||||
@@ -14567,12 +14593,26 @@ packages:
|
||||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@vitest/snapshot/0.30.1:
|
||||
resolution: {integrity: sha512-fJZqKrE99zo27uoZA/azgWyWbFvM1rw2APS05yB0JaLwUIg9aUtvvnBf4q7JWhEcAHmSwbrxKFgyBUga6tq9Tw==}
|
||||
dependencies:
|
||||
magic-string: 0.30.0
|
||||
pathe: 1.1.0
|
||||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@vitest/spy/0.30.0:
|
||||
resolution: {integrity: sha512-olTWyG5gVWdfhCrdgxWQb2K3JYtj1/ZwInFFOb4GZ2HFI91PUWHWHhLRPORxwRwVvoXD1MS1162vPJZuHlKJkg==}
|
||||
dependencies:
|
||||
tinyspy: 2.1.0
|
||||
dev: true
|
||||
|
||||
/@vitest/spy/0.30.1:
|
||||
resolution: {integrity: sha512-YfJeIf37GvTZe04ZKxzJfnNNuNSmTEGnla2OdL60C8od16f3zOfv9q9K0nNii0NfjDJRt/CVN/POuY5/zTS+BA==}
|
||||
dependencies:
|
||||
tinyspy: 2.1.0
|
||||
dev: true
|
||||
|
||||
/@vitest/utils/0.30.0:
|
||||
resolution: {integrity: sha512-qFZgoOKQ+rJV9xG4BBxgOSilnLQ2gkfG4I+z1wBuuQ3AD33zQrnB88kMFfzsot1E1AbF3dNK1e4CU7q3ojahRA==}
|
||||
dependencies:
|
||||
@@ -14581,6 +14621,14 @@ packages:
|
||||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@vitest/utils/0.30.1:
|
||||
resolution: {integrity: sha512-/c8Xv2zUVc+rnNt84QF0Y0zkfxnaGhp87K2dYJMLtLOIckPzuxLVzAtFCicGFdB4NeBHNzTRr1tNn7rCtQcWFA==}
|
||||
dependencies:
|
||||
concordance: 5.0.4
|
||||
loupe: 2.3.6
|
||||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@volar/code-gen/0.38.9:
|
||||
resolution: {integrity: sha512-n6LClucfA+37rQeskvh9vDoZV1VvCVNy++MAPKj2dT4FT+Fbmty/SDQqnsEBtdEe6E3OQctFvA/IcKsx3Mns0A==}
|
||||
dependencies:
|
||||
@@ -15502,7 +15550,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/acorn/8.8.1:
|
||||
resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==}
|
||||
@@ -15756,7 +15803,6 @@ packages:
|
||||
|
||||
/arg/4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
dev: true
|
||||
|
||||
/arg/5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
@@ -17468,6 +17514,13 @@ packages:
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
|
||||
/commander/9.5.0:
|
||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||
engines: {node: ^12.20.0 || >=14}
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/common-path-prefix/3.0.0:
|
||||
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==}
|
||||
dev: true
|
||||
@@ -17531,7 +17584,7 @@ packages:
|
||||
js-string-escape: 1.0.1
|
||||
lodash: 4.17.21
|
||||
md5-hex: 3.0.1
|
||||
semver: 7.3.8
|
||||
semver: 7.5.0
|
||||
well-known-symbols: 2.0.0
|
||||
dev: true
|
||||
|
||||
@@ -17895,7 +17948,6 @@ packages:
|
||||
|
||||
/create-require/1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
dev: true
|
||||
|
||||
/cross-fetch/3.1.5:
|
||||
resolution: {integrity: sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==}
|
||||
@@ -18944,7 +18996,6 @@ packages:
|
||||
/diff/4.0.2:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
dev: true
|
||||
|
||||
/diffie-hellman/5.0.3:
|
||||
resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==}
|
||||
@@ -23617,7 +23668,7 @@ packages:
|
||||
dependencies:
|
||||
'@jest/types': 26.6.2
|
||||
'@types/graceful-fs': 4.1.5
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
anymatch: 3.1.2
|
||||
fb-watchman: 2.0.1
|
||||
graceful-fs: 4.2.10
|
||||
@@ -23676,7 +23727,7 @@ packages:
|
||||
resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==}
|
||||
engines: {node: '>= 10.14.2'}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
graceful-fs: 4.2.10
|
||||
dev: true
|
||||
|
||||
@@ -23685,7 +23736,7 @@ packages:
|
||||
engines: {node: '>= 10.14.2'}
|
||||
dependencies:
|
||||
'@jest/types': 26.6.2
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
chalk: 4.1.2
|
||||
graceful-fs: 4.2.10
|
||||
is-ci: 2.0.0
|
||||
@@ -23697,7 +23748,7 @@ packages:
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
dependencies:
|
||||
'@jest/types': 29.3.1
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.5.0
|
||||
graceful-fs: 4.2.10
|
||||
@@ -23707,7 +23758,7 @@ packages:
|
||||
resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 7.2.0
|
||||
dev: true
|
||||
@@ -23716,7 +23767,7 @@ packages:
|
||||
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
'@types/node': 16.18.11
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
@@ -24572,7 +24623,7 @@ packages:
|
||||
resolution: {integrity: sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.4.14
|
||||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
dev: true
|
||||
|
||||
/mailhog/4.16.0:
|
||||
@@ -24598,7 +24649,6 @@ packages:
|
||||
|
||||
/make-error/1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
dev: true
|
||||
|
||||
/makeerror/1.0.12:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
@@ -26667,7 +26717,6 @@ packages:
|
||||
postcss-value-parser: 4.2.0
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.1
|
||||
dev: true
|
||||
|
||||
/postcss-import/14.1.0_postcss@8.4.20:
|
||||
resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==}
|
||||
@@ -26679,6 +26728,7 @@ packages:
|
||||
postcss-value-parser: 4.2.0
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.1
|
||||
dev: true
|
||||
|
||||
/postcss-js/4.0.0_postcss@8.4.18:
|
||||
resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==}
|
||||
@@ -26698,7 +26748,6 @@ packages:
|
||||
dependencies:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.4.19
|
||||
dev: true
|
||||
|
||||
/postcss-js/4.0.0_postcss@8.4.20:
|
||||
resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==}
|
||||
@@ -26708,6 +26757,7 @@ packages:
|
||||
dependencies:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.4.20
|
||||
dev: true
|
||||
|
||||
/postcss-load-config/3.1.4_postcss@8.4.18:
|
||||
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
|
||||
@@ -26741,6 +26791,7 @@ packages:
|
||||
lilconfig: 2.0.6
|
||||
postcss: 8.4.20
|
||||
yaml: 1.10.2
|
||||
dev: true
|
||||
|
||||
/postcss-load-config/3.1.4_v776zzvn44o7tpgzieipaairwm:
|
||||
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
|
||||
@@ -26758,7 +26809,6 @@ packages:
|
||||
postcss: 8.4.19
|
||||
ts-node: 10.9.1_@types+node@16.18.11
|
||||
yaml: 1.10.2
|
||||
dev: true
|
||||
|
||||
/postcss-loader/4.3.0_gzaxsinx64nntyd3vmdqwl7coe:
|
||||
resolution: {integrity: sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==}
|
||||
@@ -26972,7 +27022,6 @@ packages:
|
||||
dependencies:
|
||||
postcss: 8.4.19
|
||||
postcss-selector-parser: 6.0.10
|
||||
dev: true
|
||||
|
||||
/postcss-nested/6.0.0_postcss@8.4.20:
|
||||
resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==}
|
||||
@@ -26982,6 +27031,7 @@ packages:
|
||||
dependencies:
|
||||
postcss: 8.4.20
|
||||
postcss-selector-parser: 6.0.10
|
||||
dev: true
|
||||
|
||||
/postcss-normalize-charset/5.1.0_postcss@8.4.21:
|
||||
resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==}
|
||||
@@ -27198,7 +27248,6 @@ packages:
|
||||
nanoid: 3.3.4
|
||||
picocolors: 1.0.0
|
||||
source-map-js: 1.0.2
|
||||
dev: true
|
||||
|
||||
/postcss/8.4.20:
|
||||
resolution: {integrity: sha512-6Q04AXR1212bXr5fh03u8aAwbLxAQNGQ/Q1LNa0VfOI06ZAlhPHtQvE4OIdpj4kLThXilalPnmDSOD65DcHt+g==}
|
||||
@@ -28954,6 +29003,14 @@ packages:
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
|
||||
/semver/7.5.0:
|
||||
resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
dev: true
|
||||
|
||||
/send/0.18.0:
|
||||
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -30114,6 +30171,7 @@ packages:
|
||||
resolve: 1.22.1
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/tailwindcss/3.2.1_v776zzvn44o7tpgzieipaairwm:
|
||||
resolution: {integrity: sha512-Uw+GVSxp5CM48krnjHObqoOwlCt5Qo6nw1jlCRwfGy68dSYb/LwS9ZFidYGRiM+w6rMawkZiu1mEMAsHYAfoLg==}
|
||||
@@ -30147,7 +30205,6 @@ packages:
|
||||
resolve: 1.22.1
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/tailwindcss/3.2.4_postcss@8.4.20:
|
||||
resolution: {integrity: sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==}
|
||||
@@ -30688,7 +30745,6 @@ packages:
|
||||
make-error: 1.3.6
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
dev: true
|
||||
|
||||
/ts-node/10.9.1_moeqx3xmzxqxagf2sz6mqkbb7m:
|
||||
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
|
||||
@@ -31783,7 +31839,6 @@ packages:
|
||||
|
||||
/v8-compile-cache-lib/3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
dev: true
|
||||
|
||||
/v8-to-istanbul/9.0.1:
|
||||
resolution: {integrity: sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==}
|
||||
@@ -31887,6 +31942,27 @@ packages:
|
||||
- terser
|
||||
dev: true
|
||||
|
||||
/vite-node/0.30.1_@types+node@16.18.11:
|
||||
resolution: {integrity: sha512-vTikpU/J7e6LU/8iM3dzBo8ZhEiKZEKRznEMm+mJh95XhWaPrJQraT/QsT2NWmuEf+zgAoMe64PKT7hfZ1Njmg==}
|
||||
engines: {node: '>=v14.18.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.3.4
|
||||
mlly: 1.2.0
|
||||
pathe: 1.1.0
|
||||
picocolors: 1.0.0
|
||||
vite: 4.0.4_@types+node@16.18.11
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- sass
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
dev: true
|
||||
|
||||
/vite-plugin-dts/2.0.2_vite@4.0.2:
|
||||
resolution: {integrity: sha512-i3HBlrdqE2FQxQqrNwFj9P2ei/I7lt/d3Q8NOE1JCz/gNYhNf/oUeIJamIdWQUNQGhUd/Y6mtpm3kOYPw1gz8Q==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -32279,8 +32355,8 @@ packages:
|
||||
- terser
|
||||
dev: true
|
||||
|
||||
/vitest/0.30.0_jsdom@21.0.0:
|
||||
resolution: {integrity: sha512-2WW4WeTHtrLFeoiuotWvEW6khozx1NvMGYoGsNz2btdddEbqvEdPJIouIdoiC5i61Rl1ctZvm9cn2R9TcPQlzw==}
|
||||
/vitest/0.30.1_jsdom@21.0.0:
|
||||
resolution: {integrity: sha512-y35WTrSTlTxfMLttgQk4rHcaDkbHQwDP++SNwPb+7H8yb13Q3cu2EixrtHzF27iZ8v0XCciSsLg00RkPAzB/aA==}
|
||||
engines: {node: '>=v14.18.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -32312,12 +32388,12 @@ packages:
|
||||
dependencies:
|
||||
'@types/chai': 4.3.4
|
||||
'@types/chai-subset': 1.3.3
|
||||
'@types/node': 18.11.18
|
||||
'@vitest/expect': 0.30.0
|
||||
'@vitest/runner': 0.30.0
|
||||
'@vitest/snapshot': 0.30.0
|
||||
'@vitest/spy': 0.30.0
|
||||
'@vitest/utils': 0.30.0
|
||||
'@types/node': 16.18.11
|
||||
'@vitest/expect': 0.30.1
|
||||
'@vitest/runner': 0.30.1
|
||||
'@vitest/snapshot': 0.30.1
|
||||
'@vitest/spy': 0.30.1
|
||||
'@vitest/utils': 0.30.1
|
||||
acorn: 8.8.2
|
||||
acorn-walk: 8.2.0
|
||||
cac: 6.7.14
|
||||
@@ -32334,8 +32410,8 @@ packages:
|
||||
strip-literal: 1.0.1
|
||||
tinybench: 2.4.0
|
||||
tinypool: 0.4.0
|
||||
vite: 4.0.4_@types+node@18.11.18
|
||||
vite-node: 0.30.0_@types+node@18.11.18
|
||||
vite: 4.0.4_@types+node@16.18.11
|
||||
vite-node: 0.30.1_@types+node@16.18.11
|
||||
why-is-node-running: 2.2.2
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
@@ -33395,7 +33471,6 @@ packages:
|
||||
/yn/3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/yocto-queue/0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
@@ -33428,7 +33503,7 @@ packages:
|
||||
lodash.isequal: 4.5.0
|
||||
validator: 13.7.0
|
||||
optionalDependencies:
|
||||
commander: 9.4.1
|
||||
commander: 9.5.0
|
||||
dev: true
|
||||
|
||||
/zen-observable-ts/1.2.5:
|
||||
|
||||
Reference in New Issue
Block a user