Compare commits

..

96 Commits

Author SHA1 Message Date
Szilárd Dóró
899732f280 Merge pull request #1852 from nhost/changeset-release/main
chore: update versions
2023-04-21 13:40:09 +02:00
github-actions[bot]
037b566e39 chore: update versions 2023-04-21 11:23:53 +00:00
Szilárd Dóró
829f20c83c Merge pull request #1856 from nhost/renovate/vitejs-plugin-react-4.x
chore(deps): update dependency @vitejs/plugin-react to v4
2023-04-21 13:22:44 +02:00
Szilárd Dóró
f1b5a944a3 chore: add changeset 2023-04-21 11:42:44 +02:00
renovate[bot]
5ccb764ae5 chore(deps): update dependency @vitejs/plugin-react to v4 2023-04-21 09:38:43 +00:00
Szilárd Dóró
ef2b639734 Merge pull request #1839 from nhost/renovate/vueuse-core-10.x
fix(deps): update dependency @vueuse/core to v10
2023-04-21 11:36:00 +02:00
Szilárd Dóró
a5b895a827 chore: add changeset 2023-04-21 11:14:13 +02:00
renovate[bot]
b441b4bae2 fix(deps): update dependency @vueuse/core to v10 2023-04-21 09:02:30 +00:00
Szilárd Dóró
a6c67c1e4c Merge pull request #1836 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.37
2023-04-21 10:54:19 +02:00
Szilárd Dóró
7f1785ac0f chore: add changeset 2023-04-21 10:21:02 +02:00
renovate[bot]
ec74e7fe98 chore(deps): update dependency @types/react to v18.0.37 2023-04-19 12:53:55 +00:00
Szilárd Dóró
6713c198c6 Merge pull request #1833 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.9.3
2023-04-19 14:52:03 +02:00
renovate[bot]
35a6b9cf47 chore(deps): update dependency turbo to v1.9.3 2023-04-19 09:32:19 +00:00
Szilárd Dóró
79f97fad76 Merge pull request #1838 from nhost/renovate/graphql-request-6.x
fix(deps): update dependency graphql-request to v6
2023-04-19 11:29:16 +02:00
Szilárd Dóró
2faf79077d chore: add changeset 2023-04-19 11:07:46 +02:00
Szilárd Dóró
4972b6feb6 chore: sync versions, update codegen 2023-04-19 11:07:15 +02:00
Szilárd Dóró
23d5861c4c Merge pull request #1837 from rikardwissing/fix/wait-for-valid-token
Wait for valid token or sign out before establishing connection.
2023-04-19 09:31:56 +02:00
renovate[bot]
098ac5a71c fix(deps): update dependency graphql-request to v6 2023-04-18 18:54:27 +00:00
Nuno Pato
3a15329cfd Merge pull request #1849 from nhost/docs/add-compute-section
add compute section to the docs
2023-04-18 13:20:39 +00:00
Nuno Pato
c3e798aa1d asd 2023-04-18 13:15:40 +00:00
Szilárd Dóró
eec5e6a93d Merge pull request #1843 from nhost/changeset-release/main
chore: update versions
2023-04-18 15:10:41 +02:00
Nuno Pato
d964b689cd move compute resources to position 1 2023-04-18 09:37:28 +00:00
Nuno Pato
1e080c1af5 add compute section to the docs 2023-04-18 09:35:01 +00:00
github-actions[bot]
177bba7ec0 chore: update versions 2023-04-18 07:27:56 +00:00
Szilárd Dóró
a593b45dc2 Merge pull request #1845 from nhost/chore/dashboard-update-nomenclature-compute
chore: dashboard: update nomenclature for compute
2023-04-18 09:24:46 +02:00
Szilárd Dóró
b384fb8bd8 chore: merge changesets 2023-04-17 20:58:38 +02:00
Nuno Pato
abd8620ded "Resources" -> "Compute" 2023-04-17 16:58:45 +00:00
Szilárd Dóró
e62ccdcaae Merge pull request #1844 from nhost/fix/resource-memory-limit
fix(dashboard): use correct vCPUs and memory after reset
2023-04-17 14:17:49 +02:00
Szilárd Dóró
46d01b09d6 chore: use constants 2023-04-17 13:45:59 +02:00
Szilárd Dóró
ff74e712f8 fix: use correct vCPUs and memory after reset 2023-04-17 13:38:47 +02:00
Szilárd Dóró
770794ccad Merge pull request #1709 from nhost/feat/resource-sliders
feat(dashboard): Resource Sliders
2023-04-17 13:03:04 +02:00
Szilárd Dóró
aa80d1795d chore: update initial resource ratio 2023-04-17 09:28:17 +02:00
Szilárd Dóró
eaa7720c65 fix: fix tests 2023-04-17 09:17:34 +02:00
Szilárd Dóró
7f447d1182 fix: don't break the initial UI 2023-04-17 08:43:23 +02:00
Szilárd Dóró
5d3dd84762 Merge branch 'main' into feat/resource-sliders 2023-04-17 08:36:55 +02:00
Szilárd Dóró
c625317342 Merge pull request #1841 from nhost/changeset-release/main
chore: update versions
2023-04-17 08:36:12 +02:00
Rikard Wissing
117398f5dc Add changeset 2023-04-16 15:00:50 +01:00
Rikard Wissing
4e421eb4bd Refactor a bit 2023-04-16 14:55:51 +01:00
Rikard Wissing
771447b089 Remove log 2023-04-16 14:52:35 +01:00
github-actions[bot]
8ab75a4146 chore: update versions 2023-04-14 09:54:44 +00:00
Szilárd Dóró
607f465616 Merge pull request #1840 from nhost/chore/use-dialog-hook
chore(dashboard): unify payment dialog management
2023-04-14 11:50:15 +02:00
Szilárd Dóró
668c877130 chore: add changeset 2023-04-14 11:17:32 +02:00
Szilárd Dóró
4bd870eb96 chore: relocate BillingPaymentMethodForm 2023-04-14 11:15:58 +02:00
Szilárd Dóró
39b3161d91 fix: use up-to-date card information 2023-04-14 10:31:27 +02:00
Szilárd Dóró
ae090a6585 chore: unify modal management for payments 2023-04-13 16:53:30 +02:00
Rikard Wissing
be4831ae62 Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-13 15:10:14 +02:00
Szilárd Dóró
4fb0c18c32 Merge branch 'main' into feat/resource-sliders 2023-04-13 14:56:03 +02:00
Rikard Wissing
99e80cea44 Wait for valid token 2023-04-12 23:45:38 +02:00
Szilárd Dóró
f2f1c01e3b chore: refactor memory steps 2023-04-12 17:43:44 +02:00
Szilárd Dóró
2c0f98e85c Merge branch 'main' into feat/resource-sliders 2023-04-12 14:43:12 +02:00
Szilárd Dóró
20a83362ee fix: don't break builds 2023-04-11 15:28:23 +02:00
Szilárd Dóró
20b800c3e4 Merge branch 'main' into feat/resource-sliders 2023-04-11 15:24:28 +02:00
Szilárd Dóró
baaa510309 fix: add price to GQL 2023-03-31 15:28:59 +02:00
Szilárd Dóró
a84aa5ad68 Merge branch 'main' into feat/resource-sliders 2023-03-31 15:19:09 +02:00
Szilárd Dóró
4191b933c9 Merge branch 'main' into feat/resource-sliders 2023-03-24 10:57:01 +01:00
Szilárd Dóró
cf2264ce1d Merge branch 'main' into feat/resource-sliders 2023-03-20 16:25:34 +01:00
Szilárd Dóró
02dd9dd8c0 Merge branch 'main' into feat/resource-sliders 2023-03-20 12:41:38 +01:00
Szilárd Dóró
d4ff25df0f fix: adjust warning color 2023-03-10 14:19:10 +01:00
Szilárd Dóró
3d74374780 fix: update codegen 2023-03-09 10:22:43 +01:00
Szilárd Dóró
7063af678c Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-09 10:14:33 +01:00
Szilárd Dóró
2b44a1cf27 chore: add tests, fix flaky tests
- fix a UI glitch where values jumped after reset
2023-03-08 16:33:02 +01:00
Szilárd Dóró
c4f60b3645 feat: fetch plan independently 2023-03-08 15:44:30 +01:00
Szilárd Dóró
f86f658aa5 feat: improve upgrade / downgrade experience 2023-03-08 13:54:35 +01:00
Szilárd Dóró
bd02bd3f3e fix: cpu -> vcpu and selected -> available 2023-03-08 13:41:50 +01:00
Szilárd Dóró
a133faa797 chore: added submission tests 2023-03-08 11:24:17 +01:00
Szilárd Dóró
bb0269691d Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-08 10:23:48 +01:00
Szilárd Dóró
8d6171d22d chore: move test-related stuff 2023-03-07 17:23:09 +01:00
Szilárd Dóró
fff178d79f fix: fix unit tests 2023-03-07 16:59:15 +01:00
Szilárd Dóró
5e5e454ae7 chore: remove inputs from total resources 2023-03-07 16:34:40 +01:00
Szilárd Dóró
ce005f6d9e Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-07 15:37:42 +01:00
Szilárd Dóró
85889ee882 chore: add changeset 2023-03-07 10:23:33 +01:00
Szilárd Dóró
351873059e fix: use correct colors in light mode 2023-03-07 10:09:00 +01:00
Szilárd Dóró
8ccfc10522 fix: fix inner slider behaviour 2023-03-07 10:05:25 +01:00
Szilárd Dóró
82b02ca70b feat: improve slider appearance 2023-03-07 09:12:28 +01:00
Szilárd Dóró
14fc132040 fix: show footer when downgrading 2023-03-06 17:02:54 +01:00
Szilárd Dóró
a35da349ed fix: fix tests and prevent error when plan is missing 2023-03-06 16:48:43 +01:00
Szilárd Dóró
302e1d9d33 feat: retrieve plan info from the project 2023-03-06 16:18:42 +01:00
Szilárd Dóró
0db40184e8 fix: show values correctly after saving resources 2023-03-06 15:38:07 +01:00
Szilárd Dóró
d38649494e chore: added tests 2023-03-06 15:14:30 +01:00
Szilárd Dóró
5f22f1b5e5 chore: restructure to support tests
- added network requests to retrieve computation info
2023-03-06 14:13:48 +01:00
Szilárd Dóró
494f93a4bf chore: change terminology RAM -> Memory 2023-03-06 10:28:40 +01:00
Szilárd Dóró
84c8af232c fix: don't nest p in p 2023-03-03 16:54:21 +01:00
Szilárd Dóró
7f101d54da feat: add pricing placeholders 2023-03-03 16:53:28 +01:00
Szilárd Dóró
75b497412e fix: reset error, fix RAM label 2023-03-03 16:16:09 +01:00
Szilárd Dóró
5bd774afbb chore: improve readability 2023-03-03 16:11:54 +01:00
Szilárd Dóró
cdfe203fe4 Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-03 15:37:45 +01:00
Szilárd Dóró
4c7d32e944 fix: resource labels 2023-03-03 15:35:17 +01:00
Szilárd Dóró
447c622fc0 fix: remove unnecessary inputs from services 2023-03-03 15:33:01 +01:00
Szilárd Dóró
03f22aed72 chore: improved resource constants 2023-03-03 15:21:15 +01:00
Szilárd Dóró
ede5abf2ac fix: use correct width for inner slider rail 2023-03-03 15:09:33 +01:00
Szilárd Dóró
0bdd1d0e0c fix: fixed top slider vlaidation 2023-03-03 14:19:08 +01:00
Szilárd Dóró
61de7b21fd feat: improve slider rails 2023-03-03 14:00:57 +01:00
Szilárd Dóró
4b6ead1b17 feat: add validation to top slider 2023-03-03 13:18:57 +01:00
Szilárd Dóró
0b193e6310 feat: create form fragments 2023-03-03 12:15:29 +01:00
Szilárd Dóró
b21a5403fe feat: add sections for services, improve slider 2023-03-03 11:15:01 +01:00
Szilárd Dóró
e8320be941 feat: initial Resource page code 2023-03-02 16:23:23 +01:00
85 changed files with 5298 additions and 819 deletions

View File

@@ -81,7 +81,7 @@ module.exports = {
},
{
group: ['@testing-library/react*'],
message: 'Please use @/utils/testUtils instead.',
message: 'Please use @/tests/testUtils instead.',
},
],
},

View File

@@ -1,5 +1,26 @@
# @nhost/dashboard
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.14.7",
"version": "0.15.1",
"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",

View File

@@ -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',
},
});

View File

@@ -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} />;
}

View File

@@ -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,
},
});
}}

View File

@@ -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';

View File

@@ -22,7 +22,7 @@ export interface OpenDialogOptions {
/**
* Title of the dialog.
*/
title: ReactNode;
title?: ReactNode;
/**
* Component to render inside the dialog skeleton.
*/

View File

@@ -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}>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -1,11 +1,11 @@
import { queryClient, render, screen } from '@/tests/testUtils';
import type { Project } from '@/types/application';
import { ApplicationStatus } from '@/types/application';
import type { Workspace } from '@/types/workspace';
import { queryClient, render, screen } from '@/utils/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({

View File

@@ -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,
},
});
}}

View File

@@ -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>

View File

@@ -128,6 +128,13 @@ export default function SettingsSidebar({
>
General
</SettingsNavLink>
<SettingsNavLink
href="/resources"
exact={false}
onClick={handleSelect}
>
Compute Resources
</SettingsNavLink>
{isK8SPostgresEnabledInCurrentEnvironment && (
<SettingsNavLink
href="/database"

View File

@@ -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&apos;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>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ResourcesConfirmationDialog';
export { default } from './ResourcesConfirmationDialog';

View File

@@ -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();
});

View File

@@ -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&apos;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>
);
}

View File

@@ -0,0 +1 @@
export { default } from './ResourcesForm';

View File

@@ -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>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ServiceResourcesFormFragment';
export { default } from './ServiceResourcesFormFragment';

View File

@@ -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&apos;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&apos;re All Set</strong>
<p>
You have successfully allocated all the available vCPUs and
Memory.
</p>
</>
)}
</Alert>
</Box>
</Box>
);
}

View File

@@ -0,0 +1 @@
export { default } from './TotalResourcesFormFragment';

View File

@@ -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' },
]}

View File

@@ -42,7 +42,7 @@ function AlertDialog({
}}
{...props}
>
{!hideTitle && (
{!hideTitle && !!title && (
<Dialog.Title {...titleProps} id="alert-dialog-title">
{title}
</Dialog.Title>

View File

@@ -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)}`,
},

View File

@@ -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),

View File

@@ -16,7 +16,7 @@ export interface CommonDialogProps
/**
* The title of the dialog.
*/
title: ReactNode;
title?: ReactNode;
/**
* The message to display in the dialog.
*/

View 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);

View 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>
);
};
}

View File

@@ -0,0 +1,2 @@
export * from './Slider';
export { default } from './Slider';

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export * from './BillingPaymentMethodForm';
export { default as BillingPaymentMethodForm } from './BillingPaymentMethodForm';

View File

@@ -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>

View File

@@ -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} />;

View File

@@ -0,0 +1 @@
export { default as prettifyMemory } from './prettifyMemory';

View File

@@ -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,
});
}

View File

@@ -0,0 +1 @@
export { default as prettifyVCPU } from './prettifyVCPU';

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export * from './resourceSettingsValidationSchema';

View File

@@ -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
>;

View File

@@ -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
}
}

View File

@@ -36,6 +36,7 @@ fragment Project on apps {
plan {
id
name
price
isFree
}
githubRepository {

View File

@@ -0,0 +1,8 @@
query GetPlans($where: plans_bool_exp) {
plans(where: $where) {
id
name
isFree
price
}
}

View File

@@ -0,0 +1 @@
export { default } from './useProPlan';

View 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 };
}

View File

@@ -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,
},
});
},

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -0,0 +1,94 @@
import type { Project } from '@/types/application';
import { ApplicationStatus } from '@/types/application';
import type { Workspace } from '@/types/workspace';
import { faker } from '@faker-js/faker';
import type { NhostSession } from '@nhost/nextjs';
import type { NextRouter } from 'next/router';
import { vi } from 'vitest';
export const 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',
members: [],
applications: [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',
},
};

View 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],
}),
),
);

View 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,
},
},
},
},
}),
),
);

View File

@@ -0,0 +1,11 @@
import nhostGraphQLLink from './nhostGraphQLLink';
export default nhostGraphQLLink.mutation('UpdateConfig', (req, res, ctx) =>
res(
ctx.data({
updateConfig: {
id: 'ConfigConfig',
},
}),
),
);

View File

@@ -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';

View File

@@ -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',
},

View File

@@ -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.
*/

File diff suppressed because it is too large Load Diff

View File

@@ -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 });
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1 @@
export { default } from './getUnallocatedResources';

View File

@@ -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
},

View File

@@ -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"]
}

View 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.
![Compute Resources](/img/platform/compute-resources/dashboard-slider.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/apollo",
"version": "5.2.2",
"version": "5.2.3",
"description": "Nhost Apollo Client library",
"license": "MIT",
"keywords": [

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-apollo",
"version": "5.0.18",
"version": "5.0.19",
"description": "Nhost React Apollo client",
"license": "MIT",
"keywords": [

View File

@@ -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",

View File

@@ -1,5 +1,11 @@
# @nhost/vue
## 1.13.21
### Patch Changes
- a5b895a8: chore(deps): bump `@vueuse/core` to v10
## 1.13.20
### Patch Changes

View File

@@ -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

File diff suppressed because it is too large Load Diff