Compare commits
102 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7756103476 | ||
|
|
fef9456c12 | ||
|
|
2d6d56f6b0 | ||
|
|
f54be0fefd | ||
|
|
4e76d388ab | ||
|
|
84b84ab785 | ||
|
|
899732f280 | ||
|
|
037b566e39 | ||
|
|
829f20c83c | ||
|
|
f1b5a944a3 | ||
|
|
5ccb764ae5 | ||
|
|
ef2b639734 | ||
|
|
a5b895a827 | ||
|
|
b441b4bae2 | ||
|
|
a6c67c1e4c | ||
|
|
7f1785ac0f | ||
|
|
ec74e7fe98 | ||
|
|
6713c198c6 | ||
|
|
35a6b9cf47 | ||
|
|
79f97fad76 | ||
|
|
2faf79077d | ||
|
|
4972b6feb6 | ||
|
|
23d5861c4c | ||
|
|
098ac5a71c | ||
|
|
3a15329cfd | ||
|
|
c3e798aa1d | ||
|
|
eec5e6a93d | ||
|
|
d964b689cd | ||
|
|
1e080c1af5 | ||
|
|
177bba7ec0 | ||
|
|
a593b45dc2 | ||
|
|
b384fb8bd8 | ||
|
|
abd8620ded | ||
|
|
e62ccdcaae | ||
|
|
46d01b09d6 | ||
|
|
ff74e712f8 | ||
|
|
770794ccad | ||
|
|
aa80d1795d | ||
|
|
eaa7720c65 | ||
|
|
7f447d1182 | ||
|
|
5d3dd84762 | ||
|
|
c625317342 | ||
|
|
117398f5dc | ||
|
|
4e421eb4bd | ||
|
|
771447b089 | ||
|
|
8ab75a4146 | ||
|
|
607f465616 | ||
|
|
668c877130 | ||
|
|
4bd870eb96 | ||
|
|
39b3161d91 | ||
|
|
ae090a6585 | ||
|
|
be4831ae62 | ||
|
|
4fb0c18c32 | ||
|
|
99e80cea44 | ||
|
|
f2f1c01e3b | ||
|
|
2c0f98e85c | ||
|
|
20a83362ee | ||
|
|
20b800c3e4 | ||
|
|
baaa510309 | ||
|
|
a84aa5ad68 | ||
|
|
4191b933c9 | ||
|
|
cf2264ce1d | ||
|
|
02dd9dd8c0 | ||
|
|
d4ff25df0f | ||
|
|
3d74374780 | ||
|
|
7063af678c | ||
|
|
2b44a1cf27 | ||
|
|
c4f60b3645 | ||
|
|
f86f658aa5 | ||
|
|
bd02bd3f3e | ||
|
|
a133faa797 | ||
|
|
bb0269691d | ||
|
|
8d6171d22d | ||
|
|
fff178d79f | ||
|
|
5e5e454ae7 | ||
|
|
ce005f6d9e | ||
|
|
85889ee882 | ||
|
|
351873059e | ||
|
|
8ccfc10522 | ||
|
|
82b02ca70b | ||
|
|
14fc132040 | ||
|
|
a35da349ed | ||
|
|
302e1d9d33 | ||
|
|
0db40184e8 | ||
|
|
d38649494e | ||
|
|
5f22f1b5e5 | ||
|
|
494f93a4bf | ||
|
|
84c8af232c | ||
|
|
7f101d54da | ||
|
|
75b497412e | ||
|
|
5bd774afbb | ||
|
|
cdfe203fe4 | ||
|
|
4c7d32e944 | ||
|
|
447c622fc0 | ||
|
|
03f22aed72 | ||
|
|
ede5abf2ac | ||
|
|
0bdd1d0e0c | ||
|
|
61de7b21fd | ||
|
|
4b6ead1b17 | ||
|
|
0b193e6310 | ||
|
|
b21a5403fe | ||
|
|
e8320be941 |
@@ -81,7 +81,7 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
group: ['@testing-library/react*'],
|
||||
message: 'Please use @/utils/testUtils instead.',
|
||||
message: 'Please use @/tests/testUtils instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.15.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 84b84ab7: fix(projects): filter projects by workspace
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2faf7907: chore(deps): bump `graphql-request` to v6
|
||||
- f1b5a944: chore(deps): bump `@vitejs/plugin-react` to v4
|
||||
- 7f1785ac: chore(deps): bump `@types/react` to v18.0.37
|
||||
- @nhost/react-apollo@5.0.19
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 85889ee8: feat(dashboard): add Compute management to the settings
|
||||
|
||||
## 0.14.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 668c8771: chore(dialogs): unify dialog management of payment dialogs
|
||||
|
||||
## 0.14.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.14.7",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -51,7 +51,7 @@
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^4.3.0",
|
||||
"graphql-request": "^6.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.11.2",
|
||||
"just-kebab-case": "^4.1.1",
|
||||
@@ -105,14 +105,14 @@
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.0.34",
|
||||
"@types/react": "18.0.37",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.30.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
|
||||
@@ -124,13 +124,9 @@ export default function ApplicationPaused() {
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import {
|
||||
refetchGetApplicationPlanQuery,
|
||||
useGetAppPlanAndGlobalPlansQuery,
|
||||
@@ -8,18 +7,19 @@ import {
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Checkbox from '@/ui/v2/Checkbox';
|
||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { planDescriptions } from '@/utils/planDescriptions';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useTheme } from '@mui/material';
|
||||
import getServerError from '@/utils/settings/getServerError/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
function Plan({
|
||||
planName,
|
||||
@@ -66,13 +66,15 @@ function Plan({
|
||||
}
|
||||
|
||||
export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
const theme = useTheme();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState('');
|
||||
const { closeAlertDialog } = useDialog();
|
||||
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
refetch: refetchWorkspaceAndProject,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
// get workspace payment methods
|
||||
const { data } = useGetPaymentMethodsQuery({
|
||||
variables: {
|
||||
workspaceId: currentWorkspace?.id,
|
||||
@@ -80,7 +82,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
skip: !currentWorkspace,
|
||||
});
|
||||
|
||||
const { openPaymentModal, closePaymentModal, paymentModal } = useUI();
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const paymentMethodAvailable = data?.paymentMethods.length > 0;
|
||||
|
||||
const currentPlan = plans.find((plan) => plan.id === app.plan.id);
|
||||
@@ -88,7 +90,6 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
|
||||
const isDowngrade = currentPlan.price > selectedPlan?.price;
|
||||
|
||||
// graphql mutations
|
||||
const [updateApp] = useUpdateApplicationMutation({
|
||||
refetchQueries: [
|
||||
refetchGetApplicationPlanQuery({
|
||||
@@ -98,28 +99,35 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
],
|
||||
});
|
||||
|
||||
// function handlers
|
||||
const handleUpdateAppPlan = async () => {
|
||||
await updateApp({
|
||||
variables: {
|
||||
appId: app.id,
|
||||
app: {
|
||||
planId: selectedPlan.id,
|
||||
try {
|
||||
await toast.promise(
|
||||
updateApp({
|
||||
variables: {
|
||||
appId: app.id,
|
||||
app: {
|
||||
planId: selectedPlan.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Updating plan...',
|
||||
success: `Plan has been updated successfully to ${selectedPlan.name}.`,
|
||||
error: getServerError(
|
||||
'An error occurred while updating the plan. Please try again.',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
if (isDowngrade) {
|
||||
if (close) {
|
||||
close();
|
||||
}
|
||||
await refetchWorkspaceAndProject();
|
||||
|
||||
close?.();
|
||||
closeAlertDialog();
|
||||
setShowPaymentModal(false);
|
||||
} catch (error) {
|
||||
// Note: Error is handled by the toast.
|
||||
}
|
||||
|
||||
triggerToast(
|
||||
`${currentProject.name} plan changed to ${selectedPlan.name}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangePlanClick = async () => {
|
||||
@@ -128,33 +136,30 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
}
|
||||
|
||||
if (!paymentMethodAvailable) {
|
||||
openPaymentModal();
|
||||
setShowPaymentModal(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleUpdateAppPlan();
|
||||
|
||||
if (close) {
|
||||
close();
|
||||
}
|
||||
|
||||
setShowPaymentModal(false);
|
||||
close?.();
|
||||
closeAlertDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="w-full max-w-xl rounded-lg p-6 text-left">
|
||||
<Modal
|
||||
showModal={paymentModal}
|
||||
close={closePaymentModal}
|
||||
dialogStyle={{ zIndex: theme.zIndex.modal + 1 }}
|
||||
<BaseDialog
|
||||
open={showPaymentModal}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
close={closePaymentModal}
|
||||
onPaymentMethodAdded={handleUpdateAppPlan}
|
||||
workspaceId={currentWorkspace.id}
|
||||
/>
|
||||
</Modal>
|
||||
</BaseDialog>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
@@ -217,14 +222,12 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
|
||||
export interface ChangePlanModalProps {
|
||||
/**
|
||||
* Function to close the modal if mounted on parent component.
|
||||
*
|
||||
* @deprecated Implement modal by using `openAlertDialog` hook instead.
|
||||
* Function to close the modal.
|
||||
*/
|
||||
close?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ChangePlanModal({ close }: ChangePlanModalProps) {
|
||||
export function ChangePlanModal({ onCancel }: ChangePlanModalProps) {
|
||||
const {
|
||||
query: { workspaceSlug, appSlug },
|
||||
} = useRouter();
|
||||
@@ -250,5 +253,5 @@ export function ChangePlanModal({ close }: ChangePlanModalProps) {
|
||||
const { apps, plans } = data;
|
||||
const app = apps[0];
|
||||
|
||||
return <ChangePlanModalWithData app={app} plans={plans} close={close} />;
|
||||
return <ChangePlanModalWithData app={app} plans={plans} close={onCancel} />;
|
||||
}
|
||||
|
||||
@@ -30,13 +30,9 @@ export function UnlockFeatureByUpgrading({
|
||||
variant="borderless"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Column } from 'react-table';
|
||||
import { expect, test } from 'vitest';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OpenDialogOptions {
|
||||
/**
|
||||
* Title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* Component to render inside the dialog skeleton.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,13 @@ export function CountrySelector({ value, onChange }: CountrySelectorProps) {
|
||||
value={value || null}
|
||||
onChange={(_event, inputValue) => onChange(inputValue as string)}
|
||||
placeholder="Select Country"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{countries?.map((country) => (
|
||||
<Option key={country.name} value={country.code}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import type { RuleGroup } from '@/types/dataBrowser';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Deployment } from '@/types/application';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { test, vi } from 'vitest';
|
||||
import DeploymentStatusMessage from './DeploymentStatusMessage';
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Project } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { queryClient, render, screen } from '@/utils/testUtils';
|
||||
import { mockApplication, mockWorkspace } from '@/tests/mocks';
|
||||
import { queryClient, render, screen } from '@/tests/testUtils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import OverviewDeployments from '.';
|
||||
import OverviewDeployments from './OverviewDeployments';
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
@@ -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)),
|
||||
|
||||
@@ -93,13 +93,9 @@ export default function OverviewTopBar() {
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -39,12 +39,6 @@ export interface SettingsContainerProps
|
||||
* @default 'https://docs.nhost.io/'
|
||||
*/
|
||||
docsLink?: string;
|
||||
/**
|
||||
* Props for the primary action.
|
||||
*
|
||||
* @deprecated Use `slotProps.submitButton` instead.
|
||||
*/
|
||||
primaryActionButtonProps?: ButtonProps;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
@@ -106,7 +100,6 @@ export default function SettingsContainer({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
primaryActionButtonProps,
|
||||
submitButtonText = 'Save',
|
||||
className,
|
||||
onEnabledChange,
|
||||
@@ -188,18 +181,10 @@ export default function SettingsContainer({
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={
|
||||
(submitButton || primaryActionButtonProps)?.disabled
|
||||
? 'outlined'
|
||||
: 'contained'
|
||||
}
|
||||
color={
|
||||
(submitButton || primaryActionButtonProps)?.disabled
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
variant={submitButton?.disabled ? 'outlined' : 'contained'}
|
||||
color={submitButton?.disabled ? 'secondary' : 'primary'}
|
||||
type="submit"
|
||||
{...(submitButton || primaryActionButtonProps)}
|
||||
{...submitButton}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -128,6 +128,13 @@ export default function SettingsSidebar({
|
||||
>
|
||||
General
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/resources"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Compute Resources
|
||||
</SettingsNavLink>
|
||||
{isK8SPostgresEnabledInCurrentEnvironment && (
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
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 {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
|
||||
export interface ResourcesConfirmationDialogProps {
|
||||
/**
|
||||
* Price of the new plan.
|
||||
*/
|
||||
updatedResources: {
|
||||
vcpu: number;
|
||||
memory: number;
|
||||
};
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Function to be called when the user clicks the confirm button.
|
||||
*/
|
||||
onSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ResourcesConfirmationDialog({
|
||||
updatedResources,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ResourcesConfirmationDialogProps) {
|
||||
const { data: proPlan, loading, error } = useProPlan();
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE * (updatedResources.vcpu / RESOURCE_VCPU_MULTIPLIER);
|
||||
|
||||
if (!loading && !proPlan) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this project. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
{updatedResources.vcpu > 0 ? (
|
||||
<Text className="text-center">
|
||||
Please allow some time for the selected resources to take effect.
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="text-center">
|
||||
By confirming this you will go back to the original amount of
|
||||
resources of the {proPlan.name} plan.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="grid grid-flow-row gap-4">
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="font-medium">{proPlan.name} Plan</Text>
|
||||
<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
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>${updatedPrice.toFixed(2)}/mo</Text>
|
||||
</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>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color={updatedResources.vcpu > 0 ? 'primary' : 'error'}
|
||||
onClick={onSubmit}
|
||||
autoFocus
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
<Button variant="borderless" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ResourcesConfirmationDialog';
|
||||
export { default } from './ResourcesConfirmationDialog';
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
getProPlanOnlyQuery,
|
||||
getWorkspaceAndProjectQuery,
|
||||
} from '@/tests/msw/mocks/graphql/plansQuery';
|
||||
import {
|
||||
resourcesAvailableQuery,
|
||||
resourcesUnavailableQuery,
|
||||
resourcesUpdatedQuery,
|
||||
} from '@/tests/msw/mocks/graphql/resourceSettingsQuery';
|
||||
import updateConfigMutation from '@/tests/msw/mocks/graphql/updateConfigMutation';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from '@/tests/testUtils';
|
||||
import {
|
||||
RESOURCE_MEMORY_MULTIPLIER,
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { 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(),
|
||||
})),
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
resourcesAvailableQuery,
|
||||
getProPlanOnlyQuery,
|
||||
getWorkspaceAndProjectQuery,
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Note: Workaround based on https://github.com/testing-library/user-event/issues/871#issuecomment-1059317998
|
||||
function changeSliderValue(slider: HTMLElement, value: number) {
|
||||
fireEvent.input(slider, { target: { value } });
|
||||
fireEvent.change(slider, { target: { value } });
|
||||
}
|
||||
|
||||
test('should show an empty state message that the feature must be enabled if no data is available', async () => {
|
||||
server.use(resourcesUnavailableQuery);
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show the sliders if the switch is enabled', async () => {
|
||||
server.use(resourcesUnavailableQuery);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByText(/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);
|
||||
});
|
||||
|
||||
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(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(9);
|
||||
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 8/i);
|
||||
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 16384 mib/i);
|
||||
});
|
||||
|
||||
test('should show a warning message if not all the resources are allocated', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 9/i);
|
||||
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),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should update the price when the top slider is changed', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.queryByText(/\$200\.00\/mo/i)).not.toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/\$425\.00\/mo/i)).toBeInTheDocument();
|
||||
// we display the final price in two places
|
||||
expect(screen.getAllByText(/\$475\.00\/mo/i)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should show a validation error when the form is submitted when not everything is allocated', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('should show a confirmation dialog when the form is submitted', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByRole('heading', {
|
||||
name: /confirm dedicated resources/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(
|
||||
/9 vcpus \+ 18432 mib of memory/i,
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/\$475\.00\/mo/i, {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// we need to mock the query again because the mutation updated the resources
|
||||
// and we need to return the updated values
|
||||
server.use(resourcesUpdatedQuery);
|
||||
|
||||
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();
|
||||
|
||||
expect(
|
||||
screen.getByRole('slider', { name: /total available vcpu/i }),
|
||||
).toHaveValue((9 * RESOURCE_VCPU_MULTIPLIER).toString());
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should display a red button when custom resources are disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /disable dedicated resources/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toHaveStyle({
|
||||
'background-color': '#f13154',
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide the footer when custom resource allocation is disabled', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
server.use(resourcesUnavailableQuery);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('dialog'));
|
||||
|
||||
expect(screen.queryByText(/total cost:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
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 type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { resourceSettingsValidationSchema } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
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,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import type { GetResourcesQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetResourcesDocument,
|
||||
useGetResourcesQuery,
|
||||
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';
|
||||
|
||||
function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { cpu, memory } = data?.config?.[service]?.resources?.compute || {};
|
||||
|
||||
return {
|
||||
vcpu: cpu || 0,
|
||||
memory: memory || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResourcesForm() {
|
||||
const [validationError, setValidationError] = useState<Error | null>(null);
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error: resourcesError,
|
||||
} = useGetResourcesQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: proPlan,
|
||||
loading: proPlanLoading,
|
||||
error: proPlanError,
|
||||
} = useProPlan();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetResourcesDocument],
|
||||
});
|
||||
|
||||
const initialDatabaseResources = getInitialServiceResources(data, 'postgres');
|
||||
const initialHasuraResources = getInitialServiceResources(data, 'hasura');
|
||||
const initialAuthResources = getInitialServiceResources(data, 'auth');
|
||||
const initialStorageResources = getInitialServiceResources(data, 'storage');
|
||||
|
||||
const totalInitialVCPU =
|
||||
initialDatabaseResources.vcpu +
|
||||
initialHasuraResources.vcpu +
|
||||
initialAuthResources.vcpu +
|
||||
initialStorageResources.vcpu;
|
||||
|
||||
const totalInitialMemory =
|
||||
initialDatabaseResources.memory +
|
||||
initialHasuraResources.memory +
|
||||
initialAuthResources.memory +
|
||||
initialStorageResources.memory;
|
||||
|
||||
const form = useForm<ResourceSettingsFormValues>({
|
||||
values: {
|
||||
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,
|
||||
},
|
||||
resolver: yupResolver(resourceSettingsValidationSchema),
|
||||
});
|
||||
|
||||
if (!proPlan && !proPlanLoading) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this project. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || proPlanLoading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
label="Loading resource settings..."
|
||||
delay={1000}
|
||||
className="mx-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { watch, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
const totalAvailableVCPU = enabled ? watch('totalAvailableVCPU') : 0;
|
||||
|
||||
const initialPrice =
|
||||
RESOURCE_VCPU_PRICE * (totalInitialVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE * (totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
|
||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.databaseVCPU,
|
||||
memory: formValues.databaseMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hasura: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.hasuraVCPU,
|
||||
memory: formValues.hasuraMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
auth: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.authVCPU,
|
||||
memory: formValues.authMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
storage: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.storageVCPU,
|
||||
memory: formValues.storageMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating resources...',
|
||||
success: 'Resources have been updated successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while updating resources. Please try again.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
if (!formValues.enabled) {
|
||||
form.reset({
|
||||
enabled: false,
|
||||
totalAvailableVCPU: 2000,
|
||||
totalAvailableMemory: 4096,
|
||||
hasuraVCPU: 500,
|
||||
hasuraMemory: 1536,
|
||||
databaseVCPU: 1000,
|
||||
databaseMemory: 2048,
|
||||
authVCPU: 250,
|
||||
authMemory: 256,
|
||||
storageVCPU: 250,
|
||||
storageMemory: 256,
|
||||
});
|
||||
} else {
|
||||
form.reset(null, { keepValues: true, keepDirty: false });
|
||||
}
|
||||
} catch {
|
||||
// Note: The error has already been handled by the toast.
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? 'Confirm Dedicated Resources'
|
||||
: 'Disable Dedicated Resources',
|
||||
component: (
|
||||
<ResourcesConfirmationDialog
|
||||
updatedResources={{
|
||||
vcpu: enabled ? formValues.totalAvailableVCPU : 0,
|
||||
memory: enabled ? formValues.totalAvailableMemory : 0,
|
||||
}}
|
||||
onCancel={closeDialog}
|
||||
onSubmit={async () => {
|
||||
await handleSubmit(formValues);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
titleProps: { className: 'justify-center pb-1' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (resourcesError || proPlanError) {
|
||||
throw resourcesError || proPlanError;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleConfirm}>
|
||||
<SettingsContainer
|
||||
title="Compute Resources"
|
||||
description="See how much compute you have available and customise allocation on this page."
|
||||
className="gap-0 px-0"
|
||||
showSwitch
|
||||
switchId="enabled"
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !enabled || !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
// Note: We need a custom footer because of the pricing
|
||||
// information
|
||||
footer: { className: 'hidden', 'aria-hidden': true },
|
||||
}}
|
||||
>
|
||||
{enabled ? (
|
||||
<>
|
||||
<TotalResourcesFormFragment initialPrice={initialPrice} />
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="PostgreSQL Database"
|
||||
description="Manage how much compute you need for the PostgreSQL Database."
|
||||
cpuKey="databaseVCPU"
|
||||
memoryKey="databaseMemory"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Hasura GraphQL"
|
||||
description="Manage how much compute you need for the Hasura GraphQL API."
|
||||
cpuKey="hasuraVCPU"
|
||||
memoryKey="hasuraMemory"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Auth"
|
||||
description="Manage how much compute you need for Auth."
|
||||
cpuKey="authVCPU"
|
||||
memoryKey="authMemory"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Storage"
|
||||
description="Manage how much compute you need for Storage."
|
||||
cpuKey="storageVCPU"
|
||||
memoryKey="storageMemory"
|
||||
/>
|
||||
{validationError && (
|
||||
<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>
|
||||
|
||||
<p>{validationError.message}</p>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box className={twMerge('px-4', (enabled || isDirty) && 'pb-4')}>
|
||||
<Alert className="text-left">
|
||||
Enable this feature to access custom resource allocation for
|
||||
your services.
|
||||
</Alert>
|
||||
</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>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ResourcesForm';
|
||||
@@ -0,0 +1,172 @@
|
||||
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_SERVICE_MEMORY,
|
||||
MAX_SERVICE_VCPU,
|
||||
MIN_SERVICE_MEMORY,
|
||||
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 { RESOURCE_MEMORY_STEP, RESOURCE_VCPU_STEP } from '@/utils/CONSTANTS';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export interface ServiceResourcesFormFragmentProps {
|
||||
/**
|
||||
* The title of the form fragment.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the form fragment.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Form field name for CPU.
|
||||
*/
|
||||
cpuKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
/**
|
||||
* Form field name for Memory.
|
||||
*/
|
||||
memoryKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
}
|
||||
|
||||
export default function ServiceResourcesFormFragment({
|
||||
title,
|
||||
description,
|
||||
cpuKey,
|
||||
memoryKey,
|
||||
}: ServiceResourcesFormFragmentProps) {
|
||||
const { setValue } = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
const remainingCPU = formValues.totalAvailableVCPU - totalAllocatedCPU;
|
||||
const allowedCPU = remainingCPU + formValues[cpuKey];
|
||||
|
||||
const remainingMemory =
|
||||
formValues.totalAvailableMemory - totalAllocatedMemory;
|
||||
const allowedMemory = remainingMemory + formValues[memoryKey];
|
||||
|
||||
function handleCPUChange(value: string) {
|
||||
const updatedCPU = parseFloat(value);
|
||||
const exceedsAvailableCPU =
|
||||
updatedCPU + (totalAllocatedCPU - formValues[cpuKey]) >
|
||||
formValues.totalAvailableVCPU;
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedCPU) ||
|
||||
exceedsAvailableCPU ||
|
||||
updatedCPU < MIN_SERVICE_VCPU
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(cpuKey, updatedCPU, { shouldDirty: true });
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(memoryKey, updatedMemory, { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="grid grid-flow-row gap-4 p-4">
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Text variant="h3" className="font-semibold">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text color="secondary">{description}</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Text>
|
||||
Allocated vCPUs:{' '}
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(formValues[cpuKey])}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingCPU > 0 && formValues[cpuKey] < MAX_SERVICE_VCPU && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(remainingCPU)} vCPUs
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={formValues[cpuKey]}
|
||||
onChange={(_event, value) => handleCPUChange(value.toString())}
|
||||
max={MAX_SERVICE_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
allowed={allowedCPU}
|
||||
aria-label={`${title} vCPU`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Text>
|
||||
Allocated Memory:{' '}
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(formValues[memoryKey])}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingMemory > 0 && formValues[memoryKey] < MAX_SERVICE_MEMORY && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(remainingMemory)} of Memory
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={formValues[memoryKey]}
|
||||
onChange={(_event, value) => handleMemoryChange(value.toString())}
|
||||
max={MAX_SERVICE_MEMORY}
|
||||
step={RESOURCE_MEMORY_STEP}
|
||||
allowed={allowedMemory}
|
||||
aria-label={`${title} Memory`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceResourcesFormFragment';
|
||||
export { default } from './ServiceResourcesFormFragment';
|
||||
@@ -0,0 +1,184 @@
|
||||
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 { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Slider, { sliderClasses } from '@/ui/v2/Slider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import ArrowRightIcon from '@/ui/v2/icons/ArrowRightIcon';
|
||||
import {
|
||||
RESOURCE_MEMORY_MULTIPLIER,
|
||||
RESOURCE_VCPU_MEMORY_RATIO,
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
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';
|
||||
|
||||
export interface TotalResourcesFormFragmentProps {
|
||||
/**
|
||||
* The initial price of the resources.
|
||||
*/
|
||||
initialPrice: number;
|
||||
}
|
||||
|
||||
const StyledAvailableCpuSlider = styled(Slider)(({ theme }) => ({
|
||||
[`& .${sliderClasses.rail}`]: {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function TotalResourcesFormFragment({
|
||||
initialPrice,
|
||||
}: TotalResourcesFormFragmentProps) {
|
||||
const {
|
||||
data: proPlan,
|
||||
error: proPlanError,
|
||||
loading: proPlanLoading,
|
||||
} = useProPlan();
|
||||
const { setValue } = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
|
||||
if (!proPlan && !proPlanLoading) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this projectee. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (proPlanError) {
|
||||
throw proPlanError;
|
||||
}
|
||||
|
||||
const allocatedCPU =
|
||||
formValues.databaseVCPU +
|
||||
formValues.hasuraVCPU +
|
||||
formValues.authVCPU +
|
||||
formValues.storageVCPU;
|
||||
const allocatedMemory =
|
||||
formValues.databaseMemory +
|
||||
formValues.hasuraMemory +
|
||||
formValues.authMemory +
|
||||
formValues.storageMemory;
|
||||
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE *
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
|
||||
const { vcpu: unallocatedVCPU, memory: unallocatedMemory } =
|
||||
getUnallocatedResources(formValues);
|
||||
|
||||
const hasUnusedResources = unallocatedVCPU > 0 || unallocatedMemory > 0;
|
||||
const unusedResourceMessage = [
|
||||
unallocatedVCPU > 0 ? `${prettifyVCPU(unallocatedVCPU)} vCPUs` : '',
|
||||
unallocatedMemory > 0
|
||||
? `${prettifyMemory(unallocatedMemory)} of Memory`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
function handleCPUChange(value: string) {
|
||||
const updatedCPU = parseFloat(value);
|
||||
const updatedMemory =
|
||||
(updatedCPU / 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)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('totalAvailableVCPU', updatedCPU, { shouldDirty: true });
|
||||
setValue('totalAvailableMemory', updatedMemory, { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="px-4 pb-4">
|
||||
<Box className="rounded-md border">
|
||||
<Box className="flex flex-col gap-4 bg-transparent p-4">
|
||||
<Box className="flex flex-row items-center justify-between gap-4">
|
||||
<Text color="secondary">
|
||||
Total available compute for your project:
|
||||
</Text>
|
||||
|
||||
{initialPrice !== updatedPrice && (
|
||||
<Text className="flex flex-row items-center justify-end gap-2">
|
||||
<Text component="span" color="secondary">
|
||||
${initialPrice.toFixed(2)}/mo
|
||||
</Text>
|
||||
<ArrowRightIcon />
|
||||
<Text component="span" className="font-medium">
|
||||
${updatedPrice.toFixed(2)}/mo
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-row items-center justify-start gap-4">
|
||||
<Text color="secondary">
|
||||
vCPUs:{' '}
|
||||
<Text component="span" color="primary" className="font-medium">
|
||||
{prettifyVCPU(formValues.totalAvailableVCPU)}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<Text color="secondary">
|
||||
Memory:{' '}
|
||||
<Text component="span" color="primary" className="font-medium">
|
||||
{prettifyMemory(formValues.totalAvailableMemory)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<StyledAvailableCpuSlider
|
||||
value={formValues.totalAvailableVCPU}
|
||||
onChange={(_event, value) => handleCPUChange(value.toString())}
|
||||
max={MAX_TOTAL_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
aria-label="Total Available vCPU"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity={hasUnusedResources ? 'warning' : 'info'}
|
||||
className="grid grid-flow-row gap-2 rounded-t-none rounded-b-[5px] text-left"
|
||||
>
|
||||
{hasUnusedResources ? (
|
||||
<>
|
||||
<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.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>You're All Set</strong>
|
||||
|
||||
<p>
|
||||
You have successfully allocated all the available vCPUs and
|
||||
Memory.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './TotalResourcesFormFragment';
|
||||
@@ -21,7 +21,7 @@ export function Alert({
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'rounded-sm+ bg-opacity-20 p-2 text-center text-sm+',
|
||||
'rounded-sm+ bg-opacity-20 p-4 text-center text-sm+ motion-safe:transition-colors',
|
||||
className,
|
||||
)}
|
||||
sx={[
|
||||
@@ -32,11 +32,11 @@ export function Alert({
|
||||
},
|
||||
severity === 'warning' && {
|
||||
backgroundColor: 'warning.light',
|
||||
color: 'warning.dark',
|
||||
color: 'text.primary',
|
||||
},
|
||||
severity === 'success' && {
|
||||
backgroundColor: 'success.light',
|
||||
color: 'success.main',
|
||||
color: 'success.dark',
|
||||
},
|
||||
severity === 'info' && { backgroundColor: 'primary.light' },
|
||||
]}
|
||||
|
||||
@@ -42,7 +42,7 @@ function AlertDialog({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{!hideTitle && (
|
||||
{!hideTitle && !!title && (
|
||||
<Dialog.Title {...titleProps} id="alert-dialog-title">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
|
||||
@@ -141,6 +141,7 @@ const ContainedButton = forwardRef(
|
||||
backgroundColor: 'error.dark',
|
||||
},
|
||||
'&:focus': {
|
||||
backgroundColor: 'error.main',
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 2px ${alpha(theme.palette.error.main, 0.3)}`,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ function Dialog({
|
||||
aria-describedby="dialog-description"
|
||||
{...props}
|
||||
>
|
||||
{!hideTitle && (
|
||||
{!hideTitle && !!title && (
|
||||
<DialogTitle
|
||||
sx={{
|
||||
padding: (theme) => theme.spacing(3, 3, 1.5, 3),
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface CommonDialogProps
|
||||
/**
|
||||
* The title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* The message to display in the dialog.
|
||||
*/
|
||||
|
||||
81
dashboard/src/components/ui/v2/Slider/Slider.tsx
Normal file
81
dashboard/src/components/ui/v2/Slider/Slider.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
|
||||
import MaterialSlider, {
|
||||
sliderClasses as materialSliderClasses,
|
||||
} from '@mui/material/Slider';
|
||||
import type { ForwardedRef, PropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import SliderRail from './SliderRail';
|
||||
|
||||
export interface SliderProps
|
||||
extends PropsWithoutRef<Omit<MaterialSliderProps, 'color'>> {
|
||||
/**
|
||||
* The maximum allowed value of the slider. The rail will be colored up to
|
||||
* this value.
|
||||
*/
|
||||
allowed?: number;
|
||||
}
|
||||
|
||||
const StyledSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
[`& .${materialSliderClasses.mark}`]: {
|
||||
height: 6,
|
||||
width: 1,
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
},
|
||||
[`& .${materialSliderClasses.rail}`]: {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
height: 6,
|
||||
},
|
||||
[`& .${materialSliderClasses.markActive}`]: {
|
||||
opacity: 0,
|
||||
},
|
||||
[`& .${materialSliderClasses.track}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
height: 6,
|
||||
},
|
||||
[`& .${materialSliderClasses.thumb}`]: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
'&:before': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
[`& .${materialSliderClasses.thumbColorPrimary}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
[`&:focus, &:hover, &.${materialSliderClasses.active}, &.${materialSliderClasses.focusVisible}`]:
|
||||
{
|
||||
boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.3)}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function Slider(
|
||||
{ allowed, components, ...props }: SliderProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
return (
|
||||
<StyledSlider
|
||||
ref={ref}
|
||||
components={{
|
||||
Rail: SliderRail({
|
||||
value: allowed,
|
||||
max: props.max,
|
||||
marks: props.marks,
|
||||
step: props.step,
|
||||
}),
|
||||
...components,
|
||||
}}
|
||||
color="primary"
|
||||
{...props}
|
||||
marks={allowed > 0 ? false : props.marks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { materialSliderClasses as sliderClasses };
|
||||
|
||||
Slider.displayName = 'NhostSlider';
|
||||
|
||||
export default forwardRef(Slider);
|
||||
69
dashboard/src/components/ui/v2/Slider/SliderRail.tsx
Normal file
69
dashboard/src/components/ui/v2/Slider/SliderRail.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
|
||||
import MaterialSlider, {
|
||||
sliderClasses as materialSliderClasses,
|
||||
} from '@mui/material/Slider';
|
||||
|
||||
const StyledRail = styled(Box)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
height: 8,
|
||||
top: '50%',
|
||||
width: '100%',
|
||||
borderRadius: 3,
|
||||
transform: 'translateY(-50%)',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const StyledInnerSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: 6,
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
[`& .${materialSliderClasses.rail}`]: {
|
||||
height: 6,
|
||||
opacity: 1,
|
||||
background: theme.palette.grey[200],
|
||||
},
|
||||
[`& .${materialSliderClasses.track}`]: {
|
||||
borderRadius: 0,
|
||||
border: 'none',
|
||||
height: 6,
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'light'
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
[`& .${materialSliderClasses.markActive}`]: {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.5),
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface SliderRailProps extends MaterialSliderProps {}
|
||||
|
||||
export default function SliderRail({
|
||||
value,
|
||||
...railAttributes
|
||||
}: SliderRailProps) {
|
||||
return function Rail(props: BoxProps) {
|
||||
return (
|
||||
<StyledRail component="span" {...props}>
|
||||
{value > 0 && (
|
||||
<StyledInnerSlider
|
||||
{...railAttributes}
|
||||
value={value}
|
||||
disabled
|
||||
components={{ Thumb: () => null }}
|
||||
/>
|
||||
)}
|
||||
</StyledRail>
|
||||
);
|
||||
};
|
||||
}
|
||||
2
dashboard/src/components/ui/v2/Slider/index.ts
Normal file
2
dashboard/src/components/ui/v2/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Slider';
|
||||
export { default } from './Slider';
|
||||
@@ -27,13 +27,11 @@ const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PK
|
||||
: null;
|
||||
|
||||
type AddPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: () => Promise<void>;
|
||||
onPaymentMethodAdded?: () => void;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
function AddPaymentMethodForm({
|
||||
close,
|
||||
onPaymentMethodAdded,
|
||||
workspaceId,
|
||||
}: AddPaymentMethodFormProps) {
|
||||
@@ -141,9 +139,7 @@ function AddPaymentMethodForm({
|
||||
|
||||
// payment method added successfylly
|
||||
|
||||
triggerToast(`New payment method added`);
|
||||
|
||||
close();
|
||||
triggerToast('New payment method has been added to the workspace.');
|
||||
|
||||
discordAnnounce(
|
||||
`(${user.email}) added a new credit card to workspace id: ${workspaceId}.`,
|
||||
@@ -205,26 +201,27 @@ function AddPaymentMethodForm({
|
||||
);
|
||||
}
|
||||
|
||||
type BillingPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: (e?: any) => Promise<void>;
|
||||
export interface BillingPaymentMethodFormProps {
|
||||
/**
|
||||
* Callback function to run after a payment method is added.
|
||||
*/
|
||||
onPaymentMethodAdded?: (e?: any) => void;
|
||||
/**
|
||||
* Workspace identifier.
|
||||
*/
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function BillingPaymentMethodForm({
|
||||
close,
|
||||
export default function BillingPaymentMethodForm({
|
||||
onPaymentMethodAdded,
|
||||
workspaceId,
|
||||
}: BillingPaymentMethodFormProps) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<AddPaymentMethodForm
|
||||
close={close}
|
||||
onPaymentMethodAdded={onPaymentMethodAdded}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillingPaymentMethodForm;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BillingPaymentMethodForm';
|
||||
export { default as BillingPaymentMethodForm } from './BillingPaymentMethodForm';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import type { GetPaymentMethodsFragment } from '@/generated/graphql';
|
||||
import {
|
||||
refetchGetPaymentMethodsQuery,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
useSetNewDefaultPaymentMethodMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Table from '@/ui/v2/Table';
|
||||
@@ -21,8 +20,6 @@ import Text from '@/ui/v2/Text';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
function CheckCircle() {
|
||||
const theme = useTheme();
|
||||
@@ -44,15 +41,8 @@ function CheckCircle() {
|
||||
}
|
||||
|
||||
export default function WorkspacePaymentMethods() {
|
||||
const router = useRouter();
|
||||
const { action } = router.query;
|
||||
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(
|
||||
action === 'add-payment-method',
|
||||
);
|
||||
const { openAlertDialog, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const { loading, error, data } = useGetPaymentMethodsQuery({
|
||||
variables: {
|
||||
@@ -230,7 +220,14 @@ export default function WorkspacePaymentMethods() {
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setShowAddPaymentMethodModal(true);
|
||||
openDialog({
|
||||
component: (
|
||||
<BillingPaymentMethodForm
|
||||
workspaceId={currentWorkspace.id}
|
||||
onPaymentMethodAdded={closeDialog}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
disabled={maxPaymentMethodsReached}
|
||||
>
|
||||
@@ -244,19 +241,6 @@ export default function WorkspacePaymentMethods() {
|
||||
payment methods.
|
||||
</Text>
|
||||
)}
|
||||
{showAddPaymentMethodModal && (
|
||||
<Modal
|
||||
showModal={showAddPaymentMethodModal}
|
||||
close={() =>
|
||||
setShowAddPaymentMethodModal(!showAddPaymentMethodModal)
|
||||
}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
workspaceId={currentWorkspace.id}
|
||||
close={() => setShowAddPaymentMethodModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, useContext, useMemo, useReducer } from 'react';
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
|
||||
export interface UIContextState {
|
||||
paymentModal: boolean;
|
||||
/**
|
||||
* Determines whether or not the dashboard is in maintenance mode.
|
||||
*/
|
||||
@@ -12,42 +11,18 @@ export interface UIContextState {
|
||||
* The date and time when maintenance mode will end.
|
||||
*/
|
||||
maintenanceEndDate: Date;
|
||||
openPaymentModal: () => void;
|
||||
closePaymentModal: () => void;
|
||||
}
|
||||
|
||||
const initialState: UIContextState = {
|
||||
paymentModal: false,
|
||||
export const UIContext = createContext<UIContextState>({
|
||||
maintenanceActive: false,
|
||||
maintenanceEndDate: null,
|
||||
openPaymentModal: () => {},
|
||||
closePaymentModal: () => {},
|
||||
};
|
||||
|
||||
export const UIContext = createContext<UIContextState>(initialState);
|
||||
});
|
||||
|
||||
UIContext.displayName = 'UIContext';
|
||||
|
||||
function sideReducer(state: any, action: any) {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_PAYMENT_MODAL': {
|
||||
return {
|
||||
...state,
|
||||
paymentModal: !state.paymentModal,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
const [state, dispatch] = useReducer(sideReducer, initialState);
|
||||
const router = useRouter();
|
||||
|
||||
const openPaymentModal = () => dispatch({ type: 'TOGGLE_PAYMENT_MODAL' });
|
||||
const closePaymentModal = () => dispatch({ type: 'TOGGLE_PAYMENT_MODAL' });
|
||||
|
||||
const maintenanceUnlocked =
|
||||
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET &&
|
||||
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET ===
|
||||
@@ -55,9 +30,6 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
|
||||
const value: UIContextState = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
openPaymentModal,
|
||||
closePaymentModal,
|
||||
maintenanceActive: maintenanceUnlocked
|
||||
? false
|
||||
: process.env.NEXT_PUBLIC_MAINTENANCE_ACTIVE === 'true',
|
||||
@@ -67,7 +39,7 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
? new Date(Date.parse(process.env.NEXT_PUBLIC_MAINTENANCE_END_DATE))
|
||||
: null,
|
||||
}),
|
||||
[state, maintenanceUnlocked],
|
||||
[maintenanceUnlocked],
|
||||
);
|
||||
|
||||
return <UIContext.Provider value={value} {...props} />;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as prettifyMemory } from './prettifyMemory';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RESOURCE_MEMORY_MULTIPLIER } from '@/utils/CONSTANTS';
|
||||
import { prettifyNumber } from '@/utils/common/prettifyNumber';
|
||||
|
||||
/**
|
||||
* Prettifies a number of memory.
|
||||
*
|
||||
* @param vcpu - The number of memory.
|
||||
* @returns The prettified number of memory.
|
||||
*/
|
||||
export default function prettifyMemory(memory: number) {
|
||||
return prettifyNumber(memory, {
|
||||
labels: ['MiB'],
|
||||
numberOfDecimals: 3,
|
||||
separator: ' ',
|
||||
multiplier: RESOURCE_MEMORY_MULTIPLIER,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as prettifyVCPU } from './prettifyVCPU';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/CONSTANTS';
|
||||
|
||||
/**
|
||||
* Prettifies a number of vCPUs.
|
||||
*
|
||||
* @param vcpu - The number of vCPUs.
|
||||
* @returns The prettified number of vCPUs.
|
||||
*/
|
||||
export default function prettifyVCPU(vcpu: number) {
|
||||
return vcpu / RESOURCE_VCPU_MULTIPLIER;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './resourceSettingsValidationSchema';
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
RESOURCE_MEMORY_MULTIPLIER,
|
||||
RESOURCE_VCPU_MEMORY_RATIO,
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
/**
|
||||
* The minimum total CPU that has to be allocated.
|
||||
*/
|
||||
export const MIN_TOTAL_VCPU = 1 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The minimum amount of memory that has to be allocated in total.
|
||||
*/
|
||||
export const MIN_TOTAL_MEMORY =
|
||||
(MIN_TOTAL_VCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The maximum total CPU that can be allocated.
|
||||
*/
|
||||
export const MAX_TOTAL_VCPU = 60 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The maximum amount of memory that can be allocated in total.
|
||||
*/
|
||||
export const MAX_TOTAL_MEMORY = MAX_TOTAL_VCPU * RESOURCE_VCPU_MEMORY_RATIO;
|
||||
|
||||
/**
|
||||
* The minimum amount of CPU that has to be allocated per service.
|
||||
*/
|
||||
export const MIN_SERVICE_VCPU = 0.25 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The maximum amount of CPU that can be allocated per service.
|
||||
*/
|
||||
export const MAX_SERVICE_VCPU = 15 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The minimum amount of memory that has to be allocated per service.
|
||||
*/
|
||||
export const MIN_SERVICE_MEMORY = 128;
|
||||
|
||||
/**
|
||||
* The maximum amount of memory that can be allocated per service.
|
||||
*/
|
||||
export const MAX_SERVICE_MEMORY =
|
||||
(MAX_SERVICE_VCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
export const resourceSettingsValidationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
totalAvailableVCPU: Yup.number()
|
||||
.label('Total Available vCPUs')
|
||||
.required()
|
||||
.min(MIN_TOTAL_VCPU)
|
||||
.max(MAX_TOTAL_VCPU),
|
||||
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),
|
||||
});
|
||||
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
typeof resourceSettingsValidationSchema
|
||||
>;
|
||||
@@ -2,7 +2,4 @@ query GetWorkspaceAndProject($workspaceSlug: String!, $projectSlug: String) {
|
||||
workspaces(where: { slug: { _eq: $workspaceSlug } }) {
|
||||
...Workspace
|
||||
}
|
||||
projects: apps(where: { slug: { _eq: $projectSlug } }) {
|
||||
...Project
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
fragment ServiceResources on ConfigConfig {
|
||||
auth {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
storage {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetResources($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
...ServiceResources
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fragment Project on apps {
|
||||
plan {
|
||||
id
|
||||
name
|
||||
price
|
||||
isFree
|
||||
}
|
||||
githubRepository {
|
||||
|
||||
8
dashboard/src/gql/platform/getPlans.gql
Normal file
8
dashboard/src/gql/platform/getPlans.gql
Normal file
@@ -0,0 +1,8 @@
|
||||
query GetPlans($where: plans_bool_exp) {
|
||||
plans(where: $where) {
|
||||
id
|
||||
name
|
||||
isFree
|
||||
price
|
||||
}
|
||||
}
|
||||
1
dashboard/src/hooks/common/useProPlan/index.ts
Normal file
1
dashboard/src/hooks/common/useProPlan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './useProPlan';
|
||||
16
dashboard/src/hooks/common/useProPlan/useProPlan.ts
Normal file
16
dashboard/src/hooks/common/useProPlan/useProPlan.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useGetPlansQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
export default function useProPlan() {
|
||||
const { data, ...rest } = useGetPlansQuery({
|
||||
variables: {
|
||||
where: {
|
||||
name: {
|
||||
_eq: 'Pro',
|
||||
},
|
||||
},
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -234,7 +234,6 @@ export default function SettingsGeneralPage() {
|
||||
disabled: maintenanceActive,
|
||||
onClick: () => {
|
||||
openDialog({
|
||||
title: '',
|
||||
component: (
|
||||
<RemoveApplicationModal
|
||||
close={closeDialog}
|
||||
@@ -243,7 +242,6 @@ export default function SettingsGeneralPage() {
|
||||
),
|
||||
props: {
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UnlockFeatureByUpgrading } from '@/components/applications/UnlockFeatureByUpgrading';
|
||||
import Container from '@/components/layout/Container';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import ResourcesForm from '@/components/settings/resources/ResourcesForm';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator/ActivityIndicator';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function ResourceSettingsPage() {
|
||||
const { currentProject, loading } = useCurrentWorkspaceAndProject();
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={1000} label="Loading project..." />;
|
||||
}
|
||||
|
||||
if (currentProject?.plan.isFree) {
|
||||
return (
|
||||
<UnlockFeatureByUpgrading message="Unlock Compute settings by upgrading your project to the Pro plan." />
|
||||
);
|
||||
}
|
||||
|
||||
return <ResourcesForm />;
|
||||
}
|
||||
|
||||
ResourceSettingsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-8 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
{page}
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
@@ -94,10 +94,7 @@ export default function SMTPSettingsPage() {
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<UnlockFeatureByUpgrading
|
||||
message="Unlock SMTP settings by upgrading your project to the Pro plan."
|
||||
className="mt-4"
|
||||
/>
|
||||
<UnlockFeatureByUpgrading message="Unlock SMTP settings by upgrading your project to the Pro plan." />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||
import Container from '@/components/layout/Container';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import features from '@/data/features.json';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -44,7 +44,7 @@ import type { ApolloError } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { FormEvent, ReactElement } from 'react';
|
||||
import { cloneElement, isValidElement, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import slugify from 'slugify';
|
||||
@@ -67,6 +67,7 @@ export function NewProjectPageContent({
|
||||
preSelectedWorkspace,
|
||||
preSelectedRegion,
|
||||
}: NewAppPageProps) {
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -102,11 +103,7 @@ export function NewProjectPageContent({
|
||||
|
||||
const [plan, setPlan] = useState(defaultSelectedPlan);
|
||||
|
||||
// state
|
||||
const { submitState, setSubmitState } = useSubmitState();
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
|
||||
// graphql mutations
|
||||
|
||||
const [insertApp] = useInsertApplicationMutation({
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
@@ -146,13 +143,8 @@ export function NewProjectPageContent({
|
||||
setDatabasePassword(newRandomDatabasePassword);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!plan.isFree && workspace.paymentMethods.length === 0) {
|
||||
setShowPaymentModal(true);
|
||||
return;
|
||||
}
|
||||
async function handleCreateProject(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setSubmitState({
|
||||
error: null,
|
||||
@@ -225,7 +217,29 @@ export function NewProjectPageContent({
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!plan.isFree && workspace.paymentMethods.length === 0) {
|
||||
openDialog({
|
||||
component: (
|
||||
<BillingPaymentMethodForm
|
||||
onPaymentMethodAdded={() => {
|
||||
handleCreateProject(event);
|
||||
closeDialog();
|
||||
}}
|
||||
workspaceId={workspace.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleCreateProject(event);
|
||||
}
|
||||
|
||||
if (!selectedWorkspace) {
|
||||
return (
|
||||
@@ -288,7 +302,13 @@ export function NewProjectPageContent({
|
||||
const workspaceInList = workspaces.find(
|
||||
({ id }) => id === value,
|
||||
);
|
||||
setPlan(plans[0]);
|
||||
|
||||
if (numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS) {
|
||||
setPlan(plans.find((currentPlan) => !currentPlan.isFree));
|
||||
} else {
|
||||
setPlan(plans[0]);
|
||||
}
|
||||
|
||||
setSelectedWorkspace({
|
||||
id: workspaceInList.id,
|
||||
name: workspaceInList.name,
|
||||
@@ -561,23 +581,6 @@ export function NewProjectPageContent({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{showPaymentModal && (
|
||||
<Modal
|
||||
showModal={showPaymentModal}
|
||||
close={() => {
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
close={() => {
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
onPaymentMethodAdded={handleSubmit}
|
||||
workspaceId={workspace.id}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={submitState.loading}
|
||||
@@ -597,7 +600,9 @@ export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const user = useUserData();
|
||||
|
||||
const { data, loading, error } = usePrefetchNewAppQuery();
|
||||
const { data, loading, error } = usePrefetchNewAppQuery({
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
const {
|
||||
data: freeAndActiveProjectsData,
|
||||
|
||||
93
dashboard/src/tests/mocks.ts
Normal file
93
dashboard/src/tests/mocks.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Project, Workspace } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { NhostSession } from '@nhost/nextjs';
|
||||
import type { NextRouter } from 'next/router';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const mockRouter: NextRouter = {
|
||||
basePath: '',
|
||||
pathname: '/test-workspace/test-application',
|
||||
route: '/[workspaceSlug]/[appSlug]',
|
||||
asPath: '/test-workspace/test-application',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {},
|
||||
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,
|
||||
};
|
||||
|
||||
export 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: {
|
||||
id: '1',
|
||||
name: 'Starter',
|
||||
isFree: true,
|
||||
price: 0,
|
||||
},
|
||||
config: {
|
||||
hasura: {
|
||||
adminSecret: 'nhost-admin-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockWorkspace: Workspace = {
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
slug: 'test-workspace',
|
||||
workspaceMembers: [],
|
||||
projects: [mockApplication],
|
||||
};
|
||||
|
||||
export const mockSession: NhostSession = {
|
||||
accessToken: faker.random.alphaNumeric(),
|
||||
accessTokenExpiresIn: 900,
|
||||
refreshToken: faker.datatype.uuid(),
|
||||
user: {
|
||||
id: faker.datatype.uuid(),
|
||||
email: faker.internet.email(),
|
||||
displayName: faker.name.fullName(),
|
||||
createdAt: faker.date.past().toISOString(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
locale: 'en',
|
||||
isAnonymous: false,
|
||||
defaultRole: 'user',
|
||||
roles: ['user', 'me'],
|
||||
metadata: {},
|
||||
emailVerified: true,
|
||||
phoneNumber: faker.phone.number(),
|
||||
phoneNumberVerified: true,
|
||||
activeMfaType: 'totp',
|
||||
},
|
||||
};
|
||||
66
dashboard/src/tests/msw/mocks/graphql/plansQuery.ts
Normal file
66
dashboard/src/tests/msw/mocks/graphql/plansQuery.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { mockApplication, mockWorkspace } from '@/tests/mocks';
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a query that returns only the Pro plan.
|
||||
*/
|
||||
export const getProPlanOnlyQuery = nhostGraphQLLink.query(
|
||||
'GetPlans',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
plans: [
|
||||
{
|
||||
__typename: 'plans',
|
||||
id: 'dc5e805e-1bef-4d43-809e-9fdf865e211a',
|
||||
name: 'Pro',
|
||||
price: 25,
|
||||
isFree: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a query that returns all the available plans.
|
||||
*/
|
||||
export const getAllPlansQuery = nhostGraphQLLink.query(
|
||||
'GetPlans',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
plans: [
|
||||
{
|
||||
__typename: 'plans',
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
name: 'Starter',
|
||||
price: 0,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
__typename: 'plans',
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'Pro',
|
||||
price: 25,
|
||||
isFree: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a query that returns a workspace and a project.
|
||||
* Useful if you want to mock the currently selected project.
|
||||
*/
|
||||
export const getWorkspaceAndProjectQuery = nhostGraphQLLink.query(
|
||||
'GetWorkspaceAndProject',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
}),
|
||||
),
|
||||
);
|
||||
122
dashboard/src/tests/msw/mocks/graphql/resourceSettingsQuery.ts
Normal file
122
dashboard/src/tests/msw/mocks/graphql/resourceSettingsQuery.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
/**
|
||||
* Use this handler to simulate the initial state of the allocated resources.
|
||||
*/
|
||||
export const resourcesUnavailableQuery = nhostGraphQLLink.query(
|
||||
'GetResources',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
config: {
|
||||
__typename: 'ConfigConfig',
|
||||
postgres: {
|
||||
resources: null,
|
||||
},
|
||||
hasura: {
|
||||
resources: null,
|
||||
},
|
||||
auth: {
|
||||
resources: null,
|
||||
},
|
||||
storage: {
|
||||
resources: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this handler to simulate the initial state of the allocated resources.
|
||||
*/
|
||||
export const resourcesAvailableQuery = nhostGraphQLLink.query(
|
||||
'GetResources',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
config: {
|
||||
__typename: 'ConfigConfig',
|
||||
postgres: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a change in the allocated resources.
|
||||
*/
|
||||
export const resourcesUpdatedQuery = nhostGraphQLLink.query(
|
||||
'GetResources',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
config: {
|
||||
__typename: 'ConfigConfig',
|
||||
postgres: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export default nhostGraphQLLink.mutation('UpdateConfig', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
updateConfig: {
|
||||
id: 'ConfigConfig',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -2,23 +2,20 @@
|
||||
import { DialogProvider } from '@/components/common/DialogProvider';
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
import { ManagedUIContext } from '@/context/UIContext';
|
||||
import { mockRouter, mockSession } from '@/tests/mocks';
|
||||
import createTheme from '@/ui/v2/createTheme';
|
||||
import createEmotionCache from '@/utils/createEmotionCache';
|
||||
import { createHttpLink } from '@apollo/client';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import type { NhostSession } from '@nhost/nextjs';
|
||||
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 { RouterContext } from 'next/dist/shared/lib/router-context';
|
||||
import type { NextRouter } from 'next/router';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { vi } from 'vitest';
|
||||
import createEmotionCache from './createEmotionCache';
|
||||
|
||||
// Client-side cache, shared for the whole session of the user in the browser.
|
||||
const emotionCache = createEmotionCache();
|
||||
@@ -36,51 +33,6 @@ process.env = {
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'http://localhost:8080',
|
||||
};
|
||||
|
||||
export const mockRouter: NextRouter = {
|
||||
basePath: '',
|
||||
pathname: '/',
|
||||
route: '/',
|
||||
asPath: '/',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {},
|
||||
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,
|
||||
};
|
||||
|
||||
export const mockSession: NhostSession = {
|
||||
accessToken: faker.random.alphaNumeric(),
|
||||
accessTokenExpiresIn: 900,
|
||||
refreshToken: faker.datatype.uuid(),
|
||||
user: {
|
||||
id: faker.datatype.uuid(),
|
||||
email: faker.internet.email(),
|
||||
displayName: faker.name.fullName(),
|
||||
createdAt: faker.date.past().toISOString(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
locale: 'en',
|
||||
isAnonymous: false,
|
||||
defaultRole: 'user',
|
||||
roles: ['user', 'me'],
|
||||
metadata: {},
|
||||
emailVerified: true,
|
||||
phoneNumber: faker.phone.number(),
|
||||
phoneNumberVerified: true,
|
||||
activeMfaType: 'totp',
|
||||
},
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -129,7 +81,10 @@ function render<
|
||||
Container extends Element | DocumentFragment = HTMLElement,
|
||||
BaseElement extends Element | DocumentFragment = Container,
|
||||
>(ui: ReactElement, options?: RenderOptions<Q, Container, BaseElement>) {
|
||||
return rtlRender(ui, { wrapper: Providers, ...options });
|
||||
return rtlRender<Q, Container, BaseElement>(ui, {
|
||||
wrapper: Providers,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
@@ -99,7 +99,7 @@ export default function getDesignTokens(mode: PaletteMode): PaletteOptions {
|
||||
dark: '#c91737',
|
||||
},
|
||||
warning: {
|
||||
light: '#ffebd3',
|
||||
light: 'rgba(255, 154, 35, 0.2)',
|
||||
main: '#ff9a23',
|
||||
dark: '#ed7200',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -23,6 +23,40 @@ export const READ_ONLY_SCHEMAS = ['auth', 'storage'];
|
||||
*/
|
||||
export const COLOR_PREFERENCE_STORAGE_KEY = 'nhost-color-preference';
|
||||
|
||||
/**
|
||||
* For every CPU, we allocate N times the amount of RAM.
|
||||
*/
|
||||
export const RESOURCE_VCPU_MEMORY_RATIO = 2;
|
||||
|
||||
/**
|
||||
* The infrastructure uses a multiplier of 1000 to represent vCPU cores, but the
|
||||
* vCPU values are displayed in smaller units.
|
||||
*/
|
||||
export const RESOURCE_VCPU_MULTIPLIER = 1000;
|
||||
|
||||
/**
|
||||
* The infrastructure uses MiB to represent memory, but the memory values are
|
||||
* displayed in GiB.
|
||||
*/
|
||||
export const RESOURCE_MEMORY_MULTIPLIER = 1024;
|
||||
|
||||
/**
|
||||
* Number of steps between CPU cores.
|
||||
*/
|
||||
export const RESOURCE_VCPU_STEP = 0.25 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* Number of steps between GiB of RAM.
|
||||
*/
|
||||
export const RESOURCE_MEMORY_STEP = 128;
|
||||
|
||||
/**
|
||||
* Price per vCPU.
|
||||
*
|
||||
* @remarks This will be moved to the backend in the future.
|
||||
*/
|
||||
export const RESOURCE_VCPU_PRICE = 50;
|
||||
|
||||
/**
|
||||
* Maximum number of free projects a user is allowed to have.
|
||||
*/
|
||||
|
||||
1460
dashboard/src/utils/__generated__/graphql.ts
generated
1460
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './getUnallocatedResources';
|
||||
@@ -19,6 +19,7 @@
|
||||
"baseUrl": "./src",
|
||||
"useUnknownInCatchVariables": false,
|
||||
"paths": {
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/hooks/*": ["hooks/*"],
|
||||
@@ -32,7 +33,8 @@
|
||||
"@/theme/*": ["theme/*"],
|
||||
"@/generated/*": ["utils/__generated__/*"],
|
||||
"@/ui/*": ["components/ui/*"],
|
||||
"@/ui": ["components/ui/index.ts"]
|
||||
"@/ui": ["components/ui/index.ts"],
|
||||
"@/features/*": ["features/*"]
|
||||
},
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/hooks/*": ["hooks/*"],
|
||||
@@ -18,6 +19,7 @@
|
||||
"@/generated/*": ["utils/__generated__/*"],
|
||||
"@/ui/*": ["components/ui/*"],
|
||||
"@/ui": ["components/ui/index.ts"],
|
||||
"@/features/*": ["features/*"],
|
||||
"@nhost/nextjs": ["../../packages/nextjs/src/index.ts"],
|
||||
"@nhost/react-apollo": ["../../packages/react-apollo/src/index.ts"]
|
||||
}
|
||||
|
||||
38
docs/docs/platform/compute.mdx
Normal file
38
docs/docs/platform/compute.mdx
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: 'Compute Resources'
|
||||
sidebar_position: 1
|
||||
image: /img/og/platform/compute-resources.png
|
||||
---
|
||||
|
||||
Compute resources are the fundamental units that represent the processing power and memory available to your Nhost projects. The primary compute resources are vCPU and RAM. This documentation outlines the key aspects of compute resources in the context of the Nhost Cloud Platform.
|
||||
|
||||
|
||||
## Shared vs Dedicated Compute
|
||||
|
||||
Free Projects are given a total of 2 shared vCPUs and 1 GiB of RAM:
|
||||
|
||||
- Postgres: 0.5 vCPU / 256 MiB
|
||||
- Hasura GraphQL: 0.5 vCPU / 384 MiB
|
||||
- Auth: 0.5 vCPU / 256 MiB
|
||||
- Storage: 0.5 vCPU / 128 MiB
|
||||
|
||||
Pro Projects are given a total of 2 shared vCPUs and 2 GiB of RAM:
|
||||
|
||||
- Postgres: 0.5 vCPU / 512 MiB
|
||||
- Hasura GraphQL: 0.5 vCPU / 768 MiB
|
||||
- Auth: 0.5 vCPU / 384 MiB
|
||||
- Storage: 0.5 vCPU / 384 MiB
|
||||
|
||||
This is fine if your apps mostly run at low to medium load, occasionally burst for brief periods of time, and can tolerate drops in performance. It is important to understand that the availability of CPU time is not guaranteed.
|
||||
|
||||
### Dedicated Compute
|
||||
|
||||
On the other hand, for high production workloads where latency is important, or variable performance is not at all tolerable, you should consider configuring your project to use dedicated compute resources.
|
||||
With dedicated compute, resources are guaranteed for your project so you don't have to contend for them.
|
||||
|
||||
In addition to the resources fully dedicated to the project, apps are allowed to burst if demand requires it and resources are available. If properly sized, dedicated resources should guarantee the performance of your application while allowing for occassional burts.
|
||||
|
||||
To configure dedicated compute to your projects, all you have to do is navigate to the project's settings, and click on "Compute Resources" (see image below). There you will be able to choose the total amount of resources you want to dedicate, and spread those resources amongst all services.
|
||||
|
||||
|
||||

|
||||
BIN
docs/static/img/platform/compute-resources/dashboard-slider.png
vendored
Normal file
BIN
docs/static/img/platform/compute-resources/dashboard-slider.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -1,5 +1,11 @@
|
||||
# @nhost-examples/codegen-react-query
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2faf7907: chore(deps): bump `graphql-request` to v6
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/codegen-react-query",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"codegen": "graphql-codegen",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@tanstack/react-query-devtools": "^4.2.3",
|
||||
"clsx": "^1.2.1",
|
||||
"graphql": "15.7.2",
|
||||
"graphql-request": "^5.1.0",
|
||||
"graphql-request": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/apollo
|
||||
|
||||
## 5.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 117398f5: Adds async headers that waits for valid token before establishing connection to backend.
|
||||
|
||||
## 5.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/apollo",
|
||||
"version": "5.2.2",
|
||||
"version": "5.2.3",
|
||||
"description": "Nhost Apollo Client library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { setContext } from '@apollo/client/link/context'
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
|
||||
import { getMainDefinition } from '@apollo/client/utilities'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import { AuthContext, NhostClient } from '@nhost/nhost-js'
|
||||
|
||||
import { createRestartableClient } from './ws'
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
@@ -56,9 +56,33 @@ export const createApolloClient = ({
|
||||
const uri = backendUrl
|
||||
const interpreter = nhost?.auth.client.interpreter
|
||||
|
||||
let token: string | null = null
|
||||
let accessToken: AuthContext['accessToken'] | null = null
|
||||
|
||||
const isTokenValid = () =>
|
||||
!!accessToken?.value && !!accessToken?.expiresAt && accessToken?.expiresAt > new Date()
|
||||
|
||||
const isTokenValidOrNull = () => !accessToken || isTokenValid()
|
||||
|
||||
const awaitValidTokenOrNull = () => {
|
||||
if (isTokenValidOrNull()) {
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// doing this as an interval to avoid race conditions.
|
||||
const interval = setInterval(() => {
|
||||
if (isTokenValidOrNull()) {
|
||||
clearInterval(interval)
|
||||
resolve(true)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const getAuthHeaders = async () => {
|
||||
// wait for valid access token
|
||||
await awaitValidTokenOrNull()
|
||||
|
||||
function getAuthHeaders() {
|
||||
// add headers
|
||||
const resHeaders = {
|
||||
...headers,
|
||||
@@ -67,8 +91,8 @@ export const createApolloClient = ({
|
||||
|
||||
// add auth headers if signed in
|
||||
// or add 'public' role if not signed in
|
||||
if (token) {
|
||||
resHeaders.authorization = `Bearer ${token}`
|
||||
if (accessToken) {
|
||||
resHeaders.authorization = `Bearer ${accessToken.value}`
|
||||
} else {
|
||||
// ? Not sure it changes anything for Hasura
|
||||
resHeaders.role = publicRole
|
||||
@@ -97,10 +121,10 @@ export const createApolloClient = ({
|
||||
)
|
||||
)
|
||||
},
|
||||
connectionParams: () => ({
|
||||
connectionParams: async () => ({
|
||||
headers: {
|
||||
...headers,
|
||||
...getAuthHeaders()
|
||||
...(await getAuthHeaders())
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -108,12 +132,14 @@ export const createApolloClient = ({
|
||||
|
||||
const wsLink = wsClient ? new GraphQLWsLink(wsClient) : null
|
||||
|
||||
const httpLink = setContext((_, { headers }) => ({
|
||||
headers: {
|
||||
...headers,
|
||||
...getAuthHeaders()
|
||||
const httpLink = setContext(async (_, { headers }) => {
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
...(await getAuthHeaders())
|
||||
}
|
||||
}
|
||||
})).concat(createHttpLink({ uri }))
|
||||
}).concat(createHttpLink({ uri }))
|
||||
|
||||
const splitLink = wsLink
|
||||
? split(
|
||||
@@ -162,7 +188,7 @@ export const createApolloClient = ({
|
||||
interpreter?.onTransition(async (state, event) => {
|
||||
if (['SIGNOUT', 'SIGNED_IN', 'TOKEN_CHANGED'].includes(event.type)) {
|
||||
if (event.type === 'SIGNOUT') {
|
||||
token = null
|
||||
accessToken = null
|
||||
|
||||
try {
|
||||
await client.resetStore()
|
||||
@@ -175,7 +201,7 @@ export const createApolloClient = ({
|
||||
}
|
||||
|
||||
// update token
|
||||
token = state.context.accessToken.value
|
||||
accessToken = state.context.accessToken
|
||||
|
||||
if (!isBrowser || !wsClient?.isOpen()) {
|
||||
return
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/google-translation
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2faf7907: chore(deps): bump `graphql-request` to v6
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/google-translation",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"description": "Google Translation GraphQL API",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@@ -45,7 +45,7 @@
|
||||
"@graphql-yoga/node": "^2.13.13",
|
||||
"@pothos/core": "^3.22.5",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^5.0.0"
|
||||
"graphql-request": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.7",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost/react-apollo
|
||||
|
||||
## 5.0.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [117398f5]
|
||||
- @nhost/apollo@5.2.3
|
||||
|
||||
## 5.0.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/react-apollo",
|
||||
"version": "5.0.18",
|
||||
"version": "5.0.19",
|
||||
"description": "Nhost React Apollo client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"@types/node": "^16.11.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.42.1",
|
||||
"@typescript-eslint/parser": "^5.42.1",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.30.0",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
@@ -76,7 +76,7 @@
|
||||
"husky": "^8.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"turbo": "1.8.8",
|
||||
"turbo": "1.9.3",
|
||||
"typedoc": "^0.22.18",
|
||||
"typescript": "4.9.5",
|
||||
"vite": "^4.0.2",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/vue
|
||||
|
||||
## 1.13.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a5b895a8: chore(deps): bump `@vueuse/core` to v10
|
||||
|
||||
## 1.13.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/vue",
|
||||
"version": "1.13.20",
|
||||
"version": "1.13.21",
|
||||
"description": "Nhost Vue library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nhost/nhost-js": "workspace:*",
|
||||
"@vueuse/core": "^9.0.0",
|
||||
"@vueuse/core": "^10.0.0",
|
||||
"@xstate/vue": "^2.0.0",
|
||||
"jwt-decode": "^3.1.2"
|
||||
},
|
||||
|
||||
2178
pnpm-lock.yaml
generated
2178
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user