Compare commits
170 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925bf0f13f | ||
|
|
30d35f9607 | ||
|
|
755aa56f12 | ||
|
|
4c7e7c57a9 | ||
|
|
36708e2853 | ||
|
|
90c6031189 | ||
|
|
f044dbdb10 | ||
|
|
c2f3bce5f9 | ||
|
|
22d9877b97 | ||
|
|
628e96dcc3 | ||
|
|
3e9d3c42b6 | ||
|
|
a1e7b87c38 | ||
|
|
1bd800359e | ||
|
|
54a204a34e | ||
|
|
2e7ec0697e | ||
|
|
2d9baec9d4 | ||
|
|
7a7750be0b | ||
|
|
0f34f0c6b9 | ||
|
|
d05253183a | ||
|
|
65df016bbc | ||
|
|
3e6ee1ae97 | ||
|
|
6042ed101f | ||
|
|
384bce59bf | ||
|
|
8da291ad4d | ||
|
|
f94eb3c467 | ||
|
|
9baf3f4ac7 | ||
|
|
9c406548e3 | ||
|
|
1c08cd1949 | ||
|
|
adc828a582 | ||
|
|
f1ec6b9a93 | ||
|
|
233b7e383e | ||
|
|
7ea469a1e3 | ||
|
|
ebd218c180 | ||
|
|
5ab1626f73 | ||
|
|
444c3b86ca | ||
|
|
7238412341 | ||
|
|
f6639ae05c | ||
|
|
d8ceccec5d | ||
|
|
6db257d4c7 | ||
|
|
93dab2d183 | ||
|
|
dfc18368be | ||
|
|
f7c6e80bf2 | ||
|
|
573cac1431 | ||
|
|
d72ae3f362 | ||
|
|
49ec7ec385 | ||
|
|
7d2b4083c2 | ||
|
|
696b493745 | ||
|
|
15a117a861 | ||
|
|
e7ff1f79f8 | ||
|
|
33c7368a2e | ||
|
|
664c182c8e | ||
|
|
c1ab4e0a77 | ||
|
|
4a4bd61757 | ||
|
|
b6d05289be | ||
|
|
5857458ca5 | ||
|
|
2fb1145fe0 | ||
|
|
546d710102 | ||
|
|
7756103476 | ||
|
|
fef9456c12 | ||
|
|
2d6d56f6b0 | ||
|
|
f54be0fefd | ||
|
|
4e76d388ab | ||
|
|
84b84ab785 | ||
|
|
ed66769688 | ||
|
|
899732f280 | ||
|
|
037b566e39 | ||
|
|
829f20c83c | ||
|
|
f1b5a944a3 | ||
|
|
5ccb764ae5 | ||
|
|
ef2b639734 | ||
|
|
a5b895a827 | ||
|
|
b441b4bae2 | ||
|
|
a6c67c1e4c | ||
|
|
7f1785ac0f | ||
|
|
a0298e0bdb | ||
|
|
3fd94b1cdf | ||
|
|
61d5f7d616 | ||
|
|
cde9a0a715 | ||
|
|
eae6349b04 | ||
|
|
211b930b84 | ||
|
|
4ae463074b | ||
|
|
1c5a4746f7 | ||
|
|
d6ae1fa44a | ||
|
|
a3abb81b37 | ||
|
|
ec74e7fe98 | ||
|
|
6713c198c6 | ||
|
|
35a6b9cf47 | ||
|
|
79f97fad76 | ||
|
|
2faf79077d | ||
|
|
4972b6feb6 | ||
|
|
23d5861c4c | ||
|
|
098ac5a71c | ||
|
|
3a15329cfd | ||
|
|
c3e798aa1d | ||
|
|
eec5e6a93d | ||
|
|
d964b689cd | ||
|
|
1e080c1af5 | ||
|
|
177bba7ec0 | ||
|
|
a593b45dc2 | ||
|
|
b384fb8bd8 | ||
|
|
abd8620ded | ||
|
|
e62ccdcaae | ||
|
|
46d01b09d6 | ||
|
|
ff74e712f8 | ||
|
|
770794ccad | ||
|
|
aa80d1795d | ||
|
|
eaa7720c65 | ||
|
|
7f447d1182 | ||
|
|
5d3dd84762 | ||
|
|
c625317342 | ||
|
|
117398f5dc | ||
|
|
4e421eb4bd | ||
|
|
771447b089 | ||
|
|
8ab75a4146 | ||
|
|
607f465616 | ||
|
|
668c877130 | ||
|
|
4bd870eb96 | ||
|
|
39b3161d91 | ||
|
|
ae090a6585 | ||
|
|
be4831ae62 | ||
|
|
4fb0c18c32 | ||
|
|
99e80cea44 | ||
|
|
f2f1c01e3b | ||
|
|
2c0f98e85c | ||
|
|
20a83362ee | ||
|
|
20b800c3e4 | ||
|
|
baaa510309 | ||
|
|
a84aa5ad68 | ||
|
|
4191b933c9 | ||
|
|
cf2264ce1d | ||
|
|
02dd9dd8c0 | ||
|
|
d4ff25df0f | ||
|
|
3d74374780 | ||
|
|
7063af678c | ||
|
|
2b44a1cf27 | ||
|
|
c4f60b3645 | ||
|
|
f86f658aa5 | ||
|
|
bd02bd3f3e | ||
|
|
a133faa797 | ||
|
|
bb0269691d | ||
|
|
8d6171d22d | ||
|
|
fff178d79f | ||
|
|
5e5e454ae7 | ||
|
|
ce005f6d9e | ||
|
|
85889ee882 | ||
|
|
351873059e | ||
|
|
8ccfc10522 | ||
|
|
82b02ca70b | ||
|
|
14fc132040 | ||
|
|
a35da349ed | ||
|
|
302e1d9d33 | ||
|
|
0db40184e8 | ||
|
|
d38649494e | ||
|
|
5f22f1b5e5 | ||
|
|
494f93a4bf | ||
|
|
84c8af232c | ||
|
|
7f101d54da | ||
|
|
75b497412e | ||
|
|
5bd774afbb | ||
|
|
cdfe203fe4 | ||
|
|
4c7d32e944 | ||
|
|
447c622fc0 | ||
|
|
03f22aed72 | ||
|
|
ede5abf2ac | ||
|
|
0bdd1d0e0c | ||
|
|
61de7b21fd | ||
|
|
4b6ead1b17 | ||
|
|
0b193e6310 | ||
|
|
b21a5403fe | ||
|
|
e8320be941 |
16
.github/stale.yml
vendored
Normal file
16
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
daysUntilStale: 180
|
||||
daysUntilClose: 7
|
||||
limitPerRun: 30
|
||||
onlyLabels: []
|
||||
exemptLabels: []
|
||||
|
||||
exemptProjects: false
|
||||
exemptMilestones: false
|
||||
exemptAssignees: false
|
||||
staleLabel: stale
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
@@ -81,7 +81,7 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
group: ['@testing-library/react*'],
|
||||
message: 'Please use @/utils/testUtils instead.',
|
||||
message: 'Please use @/tests/testUtils instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [90c60311]
|
||||
- @nhost/react-apollo@5.0.20
|
||||
- @nhost/nextjs@1.13.22
|
||||
|
||||
## 0.16.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0f34f0c6: fix(projects): disallow downgrading to free plan
|
||||
- 8da291ad: chore(deps): bump `@types/react` to v18.2.0 and `@types/react-dom` to v18.2.1
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- adc828a5: fix(gql): don't enter an infinite loop when fetching remote app data
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2fb1145f: feat(compute): add support for replicas
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d8ceccec: chore(env): remove deprecated `NHOST_BACKEND_URL` environment variable
|
||||
|
||||
## 0.15.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 84b84ab7: fix(projects): filter projects by workspace
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2faf7907: chore(deps): bump `graphql-request` to v6
|
||||
- f1b5a944: chore(deps): bump `@vitejs/plugin-react` to v4
|
||||
- 7f1785ac: chore(deps): bump `@types/react` to v18.0.37
|
||||
- @nhost/react-apollo@5.0.19
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 85889ee8: feat(dashboard): add Compute management to the settings
|
||||
|
||||
## 0.14.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 668c8771: chore(dialogs): unify dialog management of payment dialogs
|
||||
|
||||
## 0.14.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.14.7",
|
||||
"version": "0.16.3",
|
||||
"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-dom": "18.0.11",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.1",
|
||||
"@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",
|
||||
@@ -147,7 +147,7 @@
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.30.0",
|
||||
"vitest": "^0.30.1",
|
||||
"webpack": "^5.75.0"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -46,6 +46,10 @@ export default function ApplicationInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid grid-flow-row gap-4">
|
||||
<div className="grid grid-flow-row justify-center gap-0.5">
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ApplicationProvisioning() {
|
||||
{currentProjectState.state === ApplicationStatus.Empty ? (
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h1">
|
||||
Setting Up {currentProject.name}
|
||||
Setting Up {currentProject?.name}
|
||||
</Text>
|
||||
<Text>This normally takes around 2 minutes</Text>
|
||||
<ActivityIndicator className="mx-auto" />
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ApplicationRestoring() {
|
||||
{currentProjectState.state === ApplicationStatus.Empty ? (
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h1">
|
||||
Setting Up {currentProject.name}
|
||||
Setting Up {currentProject?.name}
|
||||
</Text>
|
||||
|
||||
<Text>This normally takes around 2 minutes</Text>
|
||||
|
||||
@@ -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,27 +7,22 @@ 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 Link from '@/ui/v2/Link';
|
||||
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,
|
||||
price,
|
||||
setPlan,
|
||||
planId,
|
||||
selectedPlanId,
|
||||
currentPlan,
|
||||
}: any) {
|
||||
function Plan({ planName, price, setPlan, planId, selectedPlanId }: any) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -49,7 +43,7 @@ function Plan({
|
||||
component="p"
|
||||
className="self-center text-left font-medium"
|
||||
>
|
||||
{currentPlan.price > price ? 'Downgrade' : 'Upgrade'} to {planName}
|
||||
Upgrade to {planName}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -66,13 +60,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,15 +76,12 @@ 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);
|
||||
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
|
||||
|
||||
const isDowngrade = currentPlan.price > selectedPlan?.price;
|
||||
|
||||
// graphql mutations
|
||||
const [updateApp] = useUpdateApplicationMutation({
|
||||
refetchQueries: [
|
||||
refetchGetApplicationPlanQuery({
|
||||
@@ -98,28 +91,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 +128,77 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
}
|
||||
|
||||
if (!paymentMethodAvailable) {
|
||||
openPaymentModal();
|
||||
setShowPaymentModal(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleUpdateAppPlan();
|
||||
|
||||
if (close) {
|
||||
close();
|
||||
}
|
||||
|
||||
setShowPaymentModal(false);
|
||||
close?.();
|
||||
closeAlertDialog();
|
||||
};
|
||||
|
||||
if (app.plan.id !== plans.find((plan) => plan.isFree)?.id) {
|
||||
return (
|
||||
<Box className="mx-auto w-full max-w-xl rounded-lg p-6 text-left">
|
||||
<div className="flex flex-col">
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
src="/assets/upgrade.svg"
|
||||
alt="Nhost Logo"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="h3" component="h2" className="mt-2 text-center">
|
||||
Downgrade is not available
|
||||
</Text>
|
||||
|
||||
<Text className="mt-1 text-center">
|
||||
You can't downgrade from a paid plan to a free plan here.
|
||||
</Text>
|
||||
|
||||
<Text className="text-center">
|
||||
Please contact us at{' '}
|
||||
<Link href="mailto:info@nhost.io">info@nhost.io</Link> if you want
|
||||
to downgrade.
|
||||
</Text>
|
||||
|
||||
<div className="mt-6 grid grid-flow-row gap-2">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="mx-auto w-full max-w-sm"
|
||||
onClick={() => {
|
||||
if (close) {
|
||||
close();
|
||||
}
|
||||
|
||||
closeAlertDialog();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -191,9 +235,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
|
||||
<div className="mt-6 grid grid-flow-row gap-2">
|
||||
<Button onClick={handleChangePlanClick} disabled={!selectedPlan}>
|
||||
{!selectedPlan && 'Change Plan'}
|
||||
{selectedPlan && isDowngrade && 'Downgrade'}
|
||||
{selectedPlan && !isDowngrade && 'Upgrade'}
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -217,14 +259,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 +290,5 @@ export function ChangePlanModal({ close }: ChangePlanModalProps) {
|
||||
const { apps, plans } = data;
|
||||
const app = apps[0];
|
||||
|
||||
return <ChangePlanModalWithData app={app} plans={plans} close={close} />;
|
||||
return <ChangePlanModalWithData app={app} plans={plans} close={onCancel} />;
|
||||
}
|
||||
|
||||
@@ -30,13 +30,9 @@ export function UnlockFeatureByUpgrading({
|
||||
variant="borderless"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Column } from 'react-table';
|
||||
import { expect, test } from 'vitest';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OpenDialogOptions {
|
||||
/**
|
||||
* Title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* Component to render inside the dialog skeleton.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,13 @@ export function CountrySelector({ value, onChange }: CountrySelectorProps) {
|
||||
value={value || null}
|
||||
onChange={(_event, inputValue) => onChange(inputValue as string)}
|
||||
placeholder="Select Country"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{countries?.map((country) => (
|
||||
<Option key={country.name} value={country.code}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import type { RuleGroup } from '@/types/dataBrowser';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Deployment } from '@/types/application';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { test, vi } from 'vitest';
|
||||
import DeploymentStatusMessage from './DeploymentStatusMessage';
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ function ProjectLayoutContent({
|
||||
>
|
||||
{children}
|
||||
|
||||
<NextSeo title={currentProject.name} />
|
||||
<NextSeo title={currentProject?.name} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Project } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { queryClient, render, screen } from '@/utils/testUtils';
|
||||
import { mockApplication, mockWorkspace } from '@/tests/mocks';
|
||||
import { queryClient, render, screen } from '@/tests/testUtils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import OverviewDeployments from '.';
|
||||
import OverviewDeployments from './OverviewDeployments';
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
@@ -35,43 +33,6 @@ vi.mock('next/router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockApplication: Project = {
|
||||
id: '1',
|
||||
name: 'Test Application',
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
awsName: 'us-east-1',
|
||||
city: 'New York',
|
||||
countryCode: 'US',
|
||||
id: '1',
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
deployments: [],
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
providersUpdated: true,
|
||||
githubRepository: { fullName: 'test/git-project' },
|
||||
repositoryProductionBranch: null,
|
||||
nhostBaseFolder: null,
|
||||
plan: null,
|
||||
config: {
|
||||
hasura: {
|
||||
adminSecret: 'nhost-admin-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
slug: 'test-workspace',
|
||||
members: [],
|
||||
applications: [mockApplication],
|
||||
};
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
|
||||
@@ -93,13 +93,9 @@ export default function OverviewTopBar() {
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -39,12 +39,6 @@ export interface SettingsContainerProps
|
||||
* @default 'https://docs.nhost.io/'
|
||||
*/
|
||||
docsLink?: string;
|
||||
/**
|
||||
* Props for the primary action.
|
||||
*
|
||||
* @deprecated Use `slotProps.submitButton` instead.
|
||||
*/
|
||||
primaryActionButtonProps?: ButtonProps;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
@@ -106,7 +100,6 @@ export default function SettingsContainer({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
primaryActionButtonProps,
|
||||
submitButtonText = 'Save',
|
||||
className,
|
||||
onEnabledChange,
|
||||
@@ -188,18 +181,10 @@ export default function SettingsContainer({
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={
|
||||
(submitButton || primaryActionButtonProps)?.disabled
|
||||
? 'outlined'
|
||||
: 'contained'
|
||||
}
|
||||
color={
|
||||
(submitButton || primaryActionButtonProps)?.disabled
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
variant={submitButton?.disabled ? 'outlined' : 'contained'}
|
||||
color={submitButton?.disabled ? 'secondary' : 'primary'}
|
||||
type="submit"
|
||||
{...(submitButton || primaryActionButtonProps)}
|
||||
{...submitButton}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -128,6 +128,13 @@ export default function SettingsSidebar({
|
||||
>
|
||||
General
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/resources"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Compute Resources
|
||||
</SettingsNavLink>
|
||||
{isK8SPostgresEnabledInCurrentEnvironment && (
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
|
||||
@@ -22,7 +22,6 @@ import generateAppServiceUrl, {
|
||||
defaultRemoteBackendSlugs,
|
||||
} from '@/utils/common/generateAppServiceUrl';
|
||||
import { getHasuraConsoleServiceUrl } from '@/utils/env';
|
||||
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||
import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
@@ -99,10 +98,6 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
}
|
||||
|
||||
const systemEnvironmentVariables = [
|
||||
{
|
||||
key: 'NHOST_BACKEND_URL',
|
||||
value: generateRemoteAppUrl(currentProject.subdomain),
|
||||
},
|
||||
{ key: 'NHOST_SUBDOMAIN', value: currentProject.subdomain },
|
||||
{ key: 'NHOST_REGION', value: currentProject.region.awsName },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import { InfoIcon } from '@/ui/v2/icons/InfoIcon';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
RESOURCE_VCPU_PRICE_PER_MINUTE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
|
||||
export interface ResourcesConfirmationDialogProps {
|
||||
/**
|
||||
* The updated resources that the user has selected.
|
||||
*/
|
||||
formValues: ResourceSettingsFormValues;
|
||||
/**
|
||||
* 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({
|
||||
formValues,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ResourcesConfirmationDialogProps) {
|
||||
const { data: proPlan, loading, error } = useProPlan();
|
||||
|
||||
const priceForTotalAvailableVCPU =
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const totalBillableVCPU = formValues.enabled ? billableResources.vcpu : 0;
|
||||
const totalBillableMemory = formValues.enabled ? billableResources.memory : 0;
|
||||
|
||||
const updatedPrice =
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
|
||||
if (!loading && !proPlan) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this project. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const databaseResources = `${prettifyVCPU(
|
||||
formValues.database.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.database.memory)}`;
|
||||
const hasuraResources = `${prettifyVCPU(
|
||||
formValues.hasura.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.hasura.memory)}`;
|
||||
const authResources = `${prettifyVCPU(
|
||||
formValues.auth.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.auth.memory)}`;
|
||||
const storageResources = `${prettifyVCPU(
|
||||
formValues.storage.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.storage.memory)}`;
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
{totalBillableVCPU > 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-row gap-1.5">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text className="font-medium">Dedicated Resources</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
$
|
||||
{(
|
||||
(totalBillableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE_PER_MINUTE
|
||||
).toFixed(4)}
|
||||
/min
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid w-full grid-flow-row gap-1.5">
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
PostgreSQL Database
|
||||
</Text>
|
||||
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.database.replicas > 1
|
||||
? `${databaseResources} (${formValues.database.replicas} replicas)`
|
||||
: databaseResources}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
Hasura GraphQL
|
||||
</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.hasura.replicas > 1
|
||||
? `${hasuraResources} (${formValues.hasura.replicas} replicas)`
|
||||
: hasuraResources}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
Auth
|
||||
</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.auth.replicas > 1
|
||||
? `${authResources} (${formValues.auth.replicas} replicas)`
|
||||
: authResources}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs" color="secondary">
|
||||
Storage
|
||||
</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{formValues.storage.replicas > 1
|
||||
? `${storageResources} (${formValues.storage.replicas} replicas)`
|
||||
: storageResources}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-xs font-medium" color="secondary">
|
||||
Total
|
||||
</Text>
|
||||
<Text className="text-xs font-medium" color="secondary">
|
||||
{prettifyVCPU(totalBillableVCPU)} vCPU +{' '}
|
||||
{prettifyMemory(totalBillableMemory)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Box className="grid grid-flow-col items-center gap-1.5">
|
||||
<Text className="font-medium">Approximate Cost</Text>
|
||||
|
||||
<Tooltip title="$0.0012/minute for every 1 vCPU and 2 GiB of RAM">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Text>${updatedPrice.toFixed(2)}/mo</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color={totalBillableVCPU > 0 ? 'primary' : 'error'}
|
||||
onClick={onSubmit}
|
||||
autoFocus
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
<Button variant="borderless" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ResourcesConfirmationDialog';
|
||||
export { default } from './ResourcesConfirmationDialog';
|
||||
@@ -0,0 +1,506 @@
|
||||
import { mockMatchMediaValue, mockRouter } from '@/tests/mocks';
|
||||
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,
|
||||
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 { expect, test, vi } from 'vitest';
|
||||
import ResourcesForm from './ResourcesForm';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn().mockReturnValue(mockRouter),
|
||||
}));
|
||||
|
||||
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();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// 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 />);
|
||||
|
||||
expect(await screen.findByText(/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 />);
|
||||
|
||||
expect(await screen.findByText(/enable this feature/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(12);
|
||||
});
|
||||
|
||||
test('should not show an empty state message if there is data available', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(12);
|
||||
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 />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
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 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 />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
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 />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
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.getByText(/you have 1 vcpus and 2048 mib of memory unused./i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/invalid configuration/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show a confirmation dialog when the form is submitted', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database vcpu/i }),
|
||||
2 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
|
||||
2.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth vcpu/i }),
|
||||
1.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
3 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database memory/i }),
|
||||
4.75 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql memory/i }),
|
||||
4.25 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth memory/i }),
|
||||
4 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByRole('heading', {
|
||||
name: /confirm dedicated resources/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/postgresql database/i)
|
||||
.parentElement,
|
||||
).toHaveTextContent(/2 vcpu \+ 4864 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/hasura graphql/i)
|
||||
.parentElement,
|
||||
).toHaveTextContent(/2.5 vcpu \+ 4352 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/auth/i).parentElement,
|
||||
).toHaveTextContent(/1.5 vcpu \+ 4096 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/storage/i).parentElement,
|
||||
).toHaveTextContent(/3 vcpu \+ 5120 mib/i);
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/\$475\.00\/mo/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// we need to mock the query again because the mutation updated the resources
|
||||
// and we need to return the updated values
|
||||
server.use(resourcesUpdatedQuery);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole('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 />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate 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 pricing information when custom resource allocation is disabled', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
server.use(resourcesUnavailableQuery);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
|
||||
|
||||
expect(screen.queryByText(/approximate cost:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show a warning message when resources are overallocated', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
7 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/^you have 1 vCPUs and 2048 mib of memory overallocated\. reduce it before saving or increase the total amount\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should change pricing based on selected replicas', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$425\.00\/mo/i,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$525\.00\/mo/i,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
|
||||
1,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
|
||||
/approximate cost: \$425\.00\/mo/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 replica is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
20 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage replicas/i }),
|
||||
2,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
1 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
6 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(screen.getByText(/invalid configuration/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please check the form for errors and the allocation for each service and try again\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const validationErrorMessage = screen.getByLabelText(
|
||||
/vcpu and memory for this service must match the 1:2 ratio if more than one replica is selected\./i,
|
||||
);
|
||||
|
||||
expect(validationErrorMessage).toBeInTheDocument();
|
||||
expect(validationErrorMessage).toHaveStyle({ color: '#f13154' });
|
||||
});
|
||||
|
||||
test('should take replicas into account when confirming the resources', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
8.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up database
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database vcpu/i }),
|
||||
2 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database memory/i }),
|
||||
4 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up hasura
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
|
||||
3,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
|
||||
2.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql memory/i }),
|
||||
5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up auth
|
||||
changeSliderValue(screen.getByRole('slider', { name: /auth replicas/i }), 2);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth vcpu/i }),
|
||||
1.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth memory/i }),
|
||||
3 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
// setting up storage
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage replicas/i }),
|
||||
4,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
2.5 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(/postgresql database/i).parentElement,
|
||||
).toHaveTextContent(/2 vcpu \+ 4096 mib/i);
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(/hasura graphql/i).parentElement,
|
||||
).toHaveTextContent(/2\.5 vcpu \+ 5120 mib \(3 replicas\)/i);
|
||||
|
||||
expect(within(dialog).getByText(/auth/i).parentElement).toHaveTextContent(
|
||||
/1\.5 vcpu \+ 3072 mib \(2 replicas\)/i,
|
||||
);
|
||||
|
||||
expect(within(dialog).getByText(/storage/i).parentElement).toHaveTextContent(
|
||||
/2\.5 vcpu \+ 5120 mib \(4 replicas\)/i,
|
||||
);
|
||||
|
||||
// total must contain the sum of all resources when replicas are taken into
|
||||
// account
|
||||
expect(within(dialog).getByText(/total/i).parentElement).toHaveTextContent(
|
||||
/22\.5 vcpu \+ 46080 mib/i,
|
||||
);
|
||||
|
||||
expect(within(dialog).getByText(/\$0.0270\/min/i)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/\$1150\.00\/mo/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
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 { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { resourceSettingsValidationSchema } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
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 { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import ResourcesFormFooter from './ResourcesFormFooter';
|
||||
|
||||
function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { compute, replicas } = data?.config?.[service]?.resources || {};
|
||||
|
||||
return {
|
||||
replicas,
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResourcesForm() {
|
||||
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,
|
||||
database: {
|
||||
replicas: initialDatabaseResources.replicas || 1,
|
||||
vcpu: initialDatabaseResources.vcpu || 1000,
|
||||
memory: initialDatabaseResources.memory || 2048,
|
||||
},
|
||||
hasura: {
|
||||
replicas: initialHasuraResources.replicas || 1,
|
||||
vcpu: initialHasuraResources.vcpu || 500,
|
||||
memory: initialHasuraResources.memory || 1536,
|
||||
},
|
||||
auth: {
|
||||
replicas: initialAuthResources.replicas || 1,
|
||||
vcpu: initialAuthResources.vcpu || 250,
|
||||
memory: initialAuthResources.memory || 256,
|
||||
},
|
||||
storage: {
|
||||
replicas: initialStorageResources.replicas || 1,
|
||||
vcpu: initialStorageResources.vcpu || 250,
|
||||
memory: initialStorageResources.memory || 256,
|
||||
},
|
||||
},
|
||||
resolver: yupResolver(resourceSettingsValidationSchema),
|
||||
});
|
||||
|
||||
if (!proPlan && !proPlanLoading) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this project. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || proPlanLoading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
label="Loading resource settings..."
|
||||
delay={1000}
|
||||
className="mx-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { watch, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
const hasFormErrors = Object.keys(formState.errors).length > 0;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: initialDatabaseResources.replicas,
|
||||
vcpu: initialDatabaseResources.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: initialHasuraResources.replicas,
|
||||
vcpu: initialHasuraResources.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: initialAuthResources.replicas,
|
||||
vcpu: initialAuthResources.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: initialStorageResources.replicas,
|
||||
vcpu: initialStorageResources.vcpu,
|
||||
},
|
||||
);
|
||||
|
||||
const initialPrice =
|
||||
proPlan.price +
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE;
|
||||
|
||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.database.vcpu,
|
||||
memory: formValues.database.memory,
|
||||
},
|
||||
replicas: formValues.database.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hasura: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.hasura.vcpu,
|
||||
memory: formValues.hasura.memory,
|
||||
},
|
||||
replicas: formValues.hasura.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
auth: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.auth.vcpu,
|
||||
memory: formValues.auth.memory,
|
||||
},
|
||||
replicas: formValues.auth.replicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
storage: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.storage.vcpu,
|
||||
memory: formValues.storage.memory,
|
||||
},
|
||||
replicas: formValues.storage.replicas,
|
||||
}
|
||||
: 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,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 1000,
|
||||
memory: 2048,
|
||||
},
|
||||
hasura: {
|
||||
replicas: 1,
|
||||
vcpu: 500,
|
||||
memory: 1536,
|
||||
},
|
||||
auth: {
|
||||
replicas: 1,
|
||||
vcpu: 250,
|
||||
memory: 256,
|
||||
},
|
||||
storage: {
|
||||
replicas: 1,
|
||||
vcpu: 250,
|
||||
memory: 256,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
form.reset(null, { keepValues: true, keepDirty: false });
|
||||
}
|
||||
} catch {
|
||||
// Note: The error has already been handled by the toast.
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm(formValues: ResourceSettingsFormValues) {
|
||||
openDialog({
|
||||
title: formValues.enabled
|
||||
? 'Confirm Dedicated Resources'
|
||||
: 'Disable Dedicated Resources',
|
||||
component: (
|
||||
<ResourcesConfirmationDialog
|
||||
formValues={formValues}
|
||||
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."
|
||||
serviceKey="database"
|
||||
disableReplicas
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Hasura GraphQL"
|
||||
description="Manage how much compute you need for the Hasura GraphQL API."
|
||||
serviceKey="hasura"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Auth"
|
||||
description="Manage how much compute you need for Auth."
|
||||
serviceKey="auth"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Storage"
|
||||
description="Manage how much compute you need for Storage."
|
||||
serviceKey="storage"
|
||||
/>
|
||||
|
||||
{hasFormErrors && (
|
||||
<Box className="px-4 pb-4">
|
||||
<Alert
|
||||
severity="error"
|
||||
className="flex flex-col gap-2 text-left"
|
||||
>
|
||||
<strong>Invalid Configuration</strong>
|
||||
|
||||
<p>
|
||||
Please check the form for errors and the allocation for
|
||||
each service and try again.
|
||||
</p>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box className={twMerge('px-4', 'pb-4')}>
|
||||
<Alert className="text-left">
|
||||
Enable this feature to access custom resource allocation for
|
||||
your services.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ResourcesFormFooter />
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { useProPlan } from '@/hooks/common/useProPlan';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { InfoIcon } from '@/ui/v2/icons/InfoIcon';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import { useFormState, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ResourcesFormFooter() {
|
||||
const {
|
||||
data: proPlan,
|
||||
loading: proPlanLoading,
|
||||
error: proPlanError,
|
||||
} = useProPlan();
|
||||
|
||||
const formState = useFormState<ResourceSettingsFormValues>();
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const enabled = useWatch<ResourceSettingsFormValues>({ name: 'enabled' });
|
||||
const [totalAvailableVCPU, database, hasura, auth, storage] = useWatch<
|
||||
ResourceSettingsFormValues,
|
||||
['totalAvailableVCPU', 'database', 'hasura', 'auth', 'storage']
|
||||
>({
|
||||
name: ['totalAvailableVCPU', 'database', 'hasura', 'auth', 'storage'],
|
||||
});
|
||||
|
||||
if (proPlanLoading) {
|
||||
return <ActivityIndicator label="Loading plan details..." delay={1000} />;
|
||||
}
|
||||
|
||||
if (proPlanError) {
|
||||
throw proPlanError;
|
||||
}
|
||||
|
||||
const priceForTotalAvailableVCPU =
|
||||
(totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: database?.replicas,
|
||||
vcpu: database?.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: hasura?.replicas,
|
||||
vcpu: hasura?.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: auth?.replicas,
|
||||
vcpu: auth?.vcpu,
|
||||
},
|
||||
{
|
||||
replicas: storage?.replicas,
|
||||
vcpu: storage?.vcpu,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice = enabled
|
||||
? Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price
|
||||
: proPlan.price;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid items-center gap-4 border-t px-4 pt-4 lg:grid-flow-col lg:justify-between lg:gap-2"
|
||||
component="footer"
|
||||
>
|
||||
<Text>
|
||||
Learn more about{' '}
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/compute"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
Compute Resources
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
{(enabled || isDirty) && (
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-4">
|
||||
<Box className="grid grid-flow-col items-center gap-1.5">
|
||||
<Text>
|
||||
Approximate cost:{' '}
|
||||
<span className="font-medium">${updatedPrice.toFixed(2)}/mo</span>
|
||||
</Text>
|
||||
|
||||
<Tooltip title="$0.0012/minute for every 1 vCPU and 2 GiB of RAM">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant={isDirty ? 'contained' : 'outlined'}
|
||||
color={isDirty ? 'primary' : 'secondary'}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ResourcesForm';
|
||||
@@ -0,0 +1,236 @@
|
||||
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_REPLICAS,
|
||||
MAX_SERVICE_VCPU,
|
||||
MIN_SERVICE_MEMORY,
|
||||
MIN_SERVICE_REPLICAS,
|
||||
MIN_SERVICE_VCPU,
|
||||
} from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Slider from '@/ui/v2/Slider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import { ExclamationIcon } from '@/ui/v2/icons/ExclamationIcon';
|
||||
import { RESOURCE_MEMORY_STEP, RESOURCE_VCPU_STEP } from '@/utils/CONSTANTS';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export interface ServiceResourcesFormFragmentProps {
|
||||
/**
|
||||
* The title of the form fragment.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the form fragment.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Form field name for service.
|
||||
*/
|
||||
serviceKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
/**
|
||||
* Whether to disable the replicas field.
|
||||
*/
|
||||
disableReplicas?: boolean;
|
||||
}
|
||||
|
||||
export default function ServiceResourcesFormFragment({
|
||||
title,
|
||||
description,
|
||||
serviceKey,
|
||||
disableReplicas = false,
|
||||
}: ServiceResourcesFormFragmentProps) {
|
||||
const {
|
||||
setValue,
|
||||
trigger: triggerValidation,
|
||||
formState,
|
||||
} = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
const serviceValues = formValues[serviceKey];
|
||||
|
||||
// Total allocated CPU for all resources
|
||||
const totalAllocatedVCPU = Object.keys(formValues)
|
||||
.filter(
|
||||
(key) =>
|
||||
!['enabled', 'totalAvailableVCPU', 'totalAvailableMemory'].includes(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.reduce((acc, key) => acc + formValues[key].vcpu, 0);
|
||||
|
||||
// Total allocated memory for all resources
|
||||
const totalAllocatedMemory = Object.keys(formValues)
|
||||
.filter(
|
||||
(key) =>
|
||||
!['enabled', 'totalAvailableVCPU', 'totalAvailableMemory'].includes(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.reduce((acc, key) => acc + formValues[key].memory, 0);
|
||||
|
||||
const remainingVCPU = formValues.totalAvailableVCPU - totalAllocatedVCPU;
|
||||
const allowedVCPU = remainingVCPU + serviceValues.vcpu;
|
||||
|
||||
const remainingMemory =
|
||||
formValues.totalAvailableMemory - totalAllocatedMemory;
|
||||
const allowedMemory = remainingMemory + serviceValues.memory;
|
||||
|
||||
function handleReplicaChange(value: string) {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
|
||||
if (updatedReplicas < MIN_SERVICE_REPLICAS) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(`${serviceKey}.replicas`, updatedReplicas, { shouldDirty: true });
|
||||
triggerValidation(`${serviceKey}.replicas`);
|
||||
}
|
||||
|
||||
function handleVCPUChange(value: string) {
|
||||
const updatedVCPU = parseFloat(value);
|
||||
|
||||
if (Number.isNaN(updatedVCPU) || updatedVCPU < MIN_SERVICE_VCPU) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(`${serviceKey}.vcpu`, updatedVCPU, { shouldDirty: true });
|
||||
|
||||
// trigger validation for "replicas" field
|
||||
if (!disableReplicas) {
|
||||
triggerValidation(`${serviceKey}.replicas`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMemoryChange(value: string) {
|
||||
const updatedMemory = parseFloat(value);
|
||||
|
||||
if (Number.isNaN(updatedMemory) || updatedMemory < MIN_SERVICE_MEMORY) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(`${serviceKey}.memory`, updatedMemory, { shouldDirty: true });
|
||||
|
||||
// trigger validation for "replicas" field
|
||||
if (!disableReplicas) {
|
||||
triggerValidation(`${serviceKey}.replicas`);
|
||||
}
|
||||
}
|
||||
|
||||
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(serviceValues.vcpu)}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingVCPU > 0 && serviceValues.vcpu < MAX_SERVICE_VCPU && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(remainingVCPU)} vCPUs
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={serviceValues.vcpu}
|
||||
onChange={(_event, value) => handleVCPUChange(value.toString())}
|
||||
max={MAX_SERVICE_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
allowed={allowedVCPU}
|
||||
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(serviceValues.memory)}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingMemory > 0 && serviceValues.memory < MAX_SERVICE_MEMORY && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(remainingMemory)} of Memory
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={serviceValues.memory}
|
||||
onChange={(_event, value) => handleMemoryChange(value.toString())}
|
||||
max={MAX_SERVICE_MEMORY}
|
||||
step={RESOURCE_MEMORY_STEP}
|
||||
allowed={allowedMemory}
|
||||
aria-label={`${title} Memory`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!disableReplicas && (
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-start gap-2">
|
||||
<Text
|
||||
color={
|
||||
formState.errors?.[serviceKey]?.replicas?.message
|
||||
? 'error'
|
||||
: 'primary'
|
||||
}
|
||||
aria-errormessage={`${serviceKey}-replicas-error-tooltip`}
|
||||
>
|
||||
Replicas:{' '}
|
||||
<span className="font-medium">{serviceValues.replicas}</span>
|
||||
</Text>
|
||||
|
||||
{formState.errors?.[serviceKey]?.replicas?.message ? (
|
||||
<Tooltip
|
||||
title={formState.errors[serviceKey].replicas.message}
|
||||
id={`${serviceKey}-replicas-error-tooltip`}
|
||||
>
|
||||
<ExclamationIcon
|
||||
color="error"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={serviceValues.replicas}
|
||||
onChange={(_event, value) => handleReplicaChange(value.toString())}
|
||||
min={0}
|
||||
max={MAX_SERVICE_REPLICAS}
|
||||
step={1}
|
||||
aria-label={`${title} Replicas`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceResourcesFormFragment';
|
||||
export { default } from './ServiceResourcesFormFragment';
|
||||
@@ -0,0 +1,220 @@
|
||||
import { calculateBillableResources } from '@/features/settings/resources/utils/calculateBillableResources';
|
||||
import { getAllocatedResources } from '@/features/settings/resources/utils/getAllocatedResources';
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import {
|
||||
MAX_TOTAL_VCPU,
|
||||
MIN_TOTAL_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 { alpha, styled } from '@mui/material';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export interface TotalResourcesFormFragmentProps {
|
||||
/**
|
||||
* The initial price of the resources.
|
||||
*/
|
||||
initialPrice: number;
|
||||
}
|
||||
|
||||
const StyledAvailableCpuSlider = styled(Slider)(({ theme }) => ({
|
||||
[`& .${sliderClasses.rail}`]: {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function TotalResourcesFormFragment({
|
||||
initialPrice,
|
||||
}: TotalResourcesFormFragmentProps) {
|
||||
const {
|
||||
data: proPlan,
|
||||
error: proPlanError,
|
||||
loading: proPlanLoading,
|
||||
} = useProPlan();
|
||||
const { setValue } = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
|
||||
if (!proPlan && !proPlanLoading) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this projectee. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (proPlanError) {
|
||||
throw proPlanError;
|
||||
}
|
||||
|
||||
const priceForTotalAvailableVCPU =
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice =
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
|
||||
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
|
||||
getAllocatedResources(formValues);
|
||||
const remainingVCPU = formValues.totalAvailableVCPU - allocatedVCPU;
|
||||
const remainingMemory = formValues.totalAvailableMemory - allocatedMemory;
|
||||
const hasUnusedResources = remainingVCPU > 0 || remainingMemory > 0;
|
||||
const hasOverallocatedResources = remainingVCPU < 0 || remainingMemory < 0;
|
||||
|
||||
const unusedResourceMessage = [
|
||||
remainingVCPU > 0 ? `${prettifyVCPU(remainingVCPU)} vCPUs` : '',
|
||||
remainingMemory > 0 ? `${prettifyMemory(remainingMemory)} of Memory` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
const overallocatedResourceMessage = [
|
||||
remainingVCPU < 0 ? `${prettifyVCPU(-remainingVCPU)} vCPUs` : '',
|
||||
remainingMemory < 0 ? `${prettifyMemory(-remainingMemory)} of Memory` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
function handleVCPUChange(value: string) {
|
||||
const updatedVCPU = parseFloat(value);
|
||||
const updatedMemory =
|
||||
(updatedVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
if (Number.isNaN(updatedVCPU) || updatedVCPU < MIN_TOTAL_VCPU) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('totalAvailableVCPU', updatedVCPU, { 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) => handleVCPUChange(value.toString())}
|
||||
max={MAX_TOTAL_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
aria-label="Total Available vCPU"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity={
|
||||
hasUnusedResources || hasOverallocatedResources ? 'warning' : 'info'
|
||||
}
|
||||
className="grid grid-flow-row gap-2 rounded-t-none rounded-b-[5px] text-left"
|
||||
>
|
||||
{hasUnusedResources && !hasOverallocatedResources && (
|
||||
<>
|
||||
<strong>Please use all the available vCPUs and Memory</strong>
|
||||
|
||||
<p>
|
||||
You have {unusedResourceMessage} unused. Allocate it to any of
|
||||
the services before saving.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasOverallocatedResources && (
|
||||
<>
|
||||
<strong>Overallocated Resources</strong>
|
||||
|
||||
<p>
|
||||
You have {overallocatedResourceMessage} overallocated. Reduce it
|
||||
before saving or increase the total amount.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasUnusedResources && !hasOverallocatedResources && (
|
||||
<>
|
||||
<strong>You're All Set</strong>
|
||||
|
||||
<p>
|
||||
You have successfully allocated all the available vCPUs and
|
||||
Memory.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './TotalResourcesFormFragment';
|
||||
@@ -21,7 +21,7 @@ export function Alert({
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'rounded-sm+ bg-opacity-20 p-2 text-center text-sm+',
|
||||
'rounded-sm+ bg-opacity-20 p-4 text-center text-sm+ motion-safe:transition-colors',
|
||||
className,
|
||||
)}
|
||||
sx={[
|
||||
@@ -32,11 +32,11 @@ export function Alert({
|
||||
},
|
||||
severity === 'warning' && {
|
||||
backgroundColor: 'warning.light',
|
||||
color: 'warning.dark',
|
||||
color: 'text.primary',
|
||||
},
|
||||
severity === 'success' && {
|
||||
backgroundColor: 'success.light',
|
||||
color: 'success.main',
|
||||
color: 'success.dark',
|
||||
},
|
||||
severity === 'info' && { backgroundColor: 'primary.light' },
|
||||
]}
|
||||
|
||||
@@ -42,7 +42,7 @@ function AlertDialog({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{!hideTitle && (
|
||||
{!hideTitle && !!title && (
|
||||
<Dialog.Title {...titleProps} id="alert-dialog-title">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
|
||||
@@ -141,6 +141,7 @@ const ContainedButton = forwardRef(
|
||||
backgroundColor: 'error.dark',
|
||||
},
|
||||
'&:focus': {
|
||||
backgroundColor: 'error.main',
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 2px ${alpha(theme.palette.error.main, 0.3)}`,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ function Dialog({
|
||||
aria-describedby="dialog-description"
|
||||
{...props}
|
||||
>
|
||||
{!hideTitle && (
|
||||
{!hideTitle && !!title && (
|
||||
<DialogTitle
|
||||
sx={{
|
||||
padding: (theme) => theme.spacing(3, 3, 1.5, 3),
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface CommonDialogProps
|
||||
/**
|
||||
* The title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* The message to display in the dialog.
|
||||
*/
|
||||
|
||||
81
dashboard/src/components/ui/v2/Slider/Slider.tsx
Normal file
81
dashboard/src/components/ui/v2/Slider/Slider.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
|
||||
import MaterialSlider, {
|
||||
sliderClasses as materialSliderClasses,
|
||||
} from '@mui/material/Slider';
|
||||
import type { ForwardedRef, PropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import SliderRail from './SliderRail';
|
||||
|
||||
export interface SliderProps
|
||||
extends PropsWithoutRef<Omit<MaterialSliderProps, 'color'>> {
|
||||
/**
|
||||
* The maximum allowed value of the slider. The rail will be colored up to
|
||||
* this value.
|
||||
*/
|
||||
allowed?: number;
|
||||
}
|
||||
|
||||
const StyledSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
[`& .${materialSliderClasses.mark}`]: {
|
||||
height: 6,
|
||||
width: 1,
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
},
|
||||
[`& .${materialSliderClasses.rail}`]: {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
height: 6,
|
||||
},
|
||||
[`& .${materialSliderClasses.markActive}`]: {
|
||||
opacity: 0,
|
||||
},
|
||||
[`& .${materialSliderClasses.track}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
height: 6,
|
||||
},
|
||||
[`& .${materialSliderClasses.thumb}`]: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
'&:before': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
[`& .${materialSliderClasses.thumbColorPrimary}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
[`&:focus, &:hover, &.${materialSliderClasses.active}, &.${materialSliderClasses.focusVisible}`]:
|
||||
{
|
||||
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.35)}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function Slider(
|
||||
{ allowed, components, ...props }: SliderProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
return (
|
||||
<StyledSlider
|
||||
ref={ref}
|
||||
components={{
|
||||
Rail: SliderRail({
|
||||
value: allowed,
|
||||
max: props.max,
|
||||
marks: props.marks,
|
||||
step: props.step,
|
||||
}),
|
||||
...components,
|
||||
}}
|
||||
color="primary"
|
||||
{...props}
|
||||
marks={allowed > 0 ? false : props.marks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { materialSliderClasses as sliderClasses };
|
||||
|
||||
Slider.displayName = 'NhostSlider';
|
||||
|
||||
export default forwardRef(Slider);
|
||||
69
dashboard/src/components/ui/v2/Slider/SliderRail.tsx
Normal file
69
dashboard/src/components/ui/v2/Slider/SliderRail.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
|
||||
import MaterialSlider, {
|
||||
sliderClasses as materialSliderClasses,
|
||||
} from '@mui/material/Slider';
|
||||
|
||||
const StyledRail = styled(Box)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
height: 8,
|
||||
top: '50%',
|
||||
width: '100%',
|
||||
borderRadius: 3,
|
||||
transform: 'translateY(-50%)',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const StyledInnerSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: 6,
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
[`& .${materialSliderClasses.rail}`]: {
|
||||
height: 6,
|
||||
opacity: 1,
|
||||
background: theme.palette.grey[200],
|
||||
},
|
||||
[`& .${materialSliderClasses.track}`]: {
|
||||
borderRadius: 0,
|
||||
border: 'none',
|
||||
height: 6,
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'light'
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
[`& .${materialSliderClasses.markActive}`]: {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.5),
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface SliderRailProps extends MaterialSliderProps {}
|
||||
|
||||
export default function SliderRail({
|
||||
value,
|
||||
...railAttributes
|
||||
}: SliderRailProps) {
|
||||
return function Rail(props: BoxProps) {
|
||||
return (
|
||||
<StyledRail component="span" {...props}>
|
||||
{value > 0 && (
|
||||
<StyledInnerSlider
|
||||
{...railAttributes}
|
||||
value={value}
|
||||
disabled
|
||||
components={{ Thumb: () => null }}
|
||||
/>
|
||||
)}
|
||||
</StyledRail>
|
||||
);
|
||||
};
|
||||
}
|
||||
2
dashboard/src/components/ui/v2/Slider/index.ts
Normal file
2
dashboard/src/components/ui/v2/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Slider';
|
||||
export { default } from './Slider';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IconProps } from '@/ui/v2/icons';
|
||||
import SvgIcon from '@/ui/v2/icons/SvgIcon';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function ExclamationIcon(props: IconProps, ref: ForwardedRef<SVGSVGElement>) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Exclamation mark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
opacity=".2"
|
||||
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.75 5.5V4h-1.5v5.5h1.5v-4Zm0 5.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ExclamationIcon.displayName = 'NhostExclamationIcon';
|
||||
|
||||
export default forwardRef(ExclamationIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ExclamationIcon } from './ExclamationIcon';
|
||||
@@ -27,13 +27,11 @@ const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PK
|
||||
: null;
|
||||
|
||||
type AddPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: () => Promise<void>;
|
||||
onPaymentMethodAdded?: () => void;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
function AddPaymentMethodForm({
|
||||
close,
|
||||
onPaymentMethodAdded,
|
||||
workspaceId,
|
||||
}: AddPaymentMethodFormProps) {
|
||||
@@ -141,9 +139,7 @@ function AddPaymentMethodForm({
|
||||
|
||||
// payment method added successfylly
|
||||
|
||||
triggerToast(`New payment method added`);
|
||||
|
||||
close();
|
||||
triggerToast('New payment method has been added to the workspace.');
|
||||
|
||||
discordAnnounce(
|
||||
`(${user.email}) added a new credit card to workspace id: ${workspaceId}.`,
|
||||
@@ -205,26 +201,27 @@ function AddPaymentMethodForm({
|
||||
);
|
||||
}
|
||||
|
||||
type BillingPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: (e?: any) => Promise<void>;
|
||||
export interface BillingPaymentMethodFormProps {
|
||||
/**
|
||||
* Callback function to run after a payment method is added.
|
||||
*/
|
||||
onPaymentMethodAdded?: (e?: any) => void;
|
||||
/**
|
||||
* Workspace identifier.
|
||||
*/
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function BillingPaymentMethodForm({
|
||||
close,
|
||||
export default function BillingPaymentMethodForm({
|
||||
onPaymentMethodAdded,
|
||||
workspaceId,
|
||||
}: BillingPaymentMethodFormProps) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<AddPaymentMethodForm
|
||||
close={close}
|
||||
onPaymentMethodAdded={onPaymentMethodAdded}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillingPaymentMethodForm;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BillingPaymentMethodForm';
|
||||
export { default as BillingPaymentMethodForm } from './BillingPaymentMethodForm';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import type { GetPaymentMethodsFragment } from '@/generated/graphql';
|
||||
import {
|
||||
refetchGetPaymentMethodsQuery,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
useSetNewDefaultPaymentMethodMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Table from '@/ui/v2/Table';
|
||||
@@ -21,8 +20,6 @@ import Text from '@/ui/v2/Text';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
function CheckCircle() {
|
||||
const theme = useTheme();
|
||||
@@ -44,15 +41,8 @@ function CheckCircle() {
|
||||
}
|
||||
|
||||
export default function WorkspacePaymentMethods() {
|
||||
const router = useRouter();
|
||||
const { action } = router.query;
|
||||
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(
|
||||
action === 'add-payment-method',
|
||||
);
|
||||
const { openAlertDialog, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const { loading, error, data } = useGetPaymentMethodsQuery({
|
||||
variables: {
|
||||
@@ -230,7 +220,14 @@ export default function WorkspacePaymentMethods() {
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setShowAddPaymentMethodModal(true);
|
||||
openDialog({
|
||||
component: (
|
||||
<BillingPaymentMethodForm
|
||||
workspaceId={currentWorkspace.id}
|
||||
onPaymentMethodAdded={closeDialog}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
disabled={maxPaymentMethodsReached}
|
||||
>
|
||||
@@ -244,19 +241,6 @@ export default function WorkspacePaymentMethods() {
|
||||
payment methods.
|
||||
</Text>
|
||||
)}
|
||||
{showAddPaymentMethodModal && (
|
||||
<Modal
|
||||
showModal={showAddPaymentMethodModal}
|
||||
close={() =>
|
||||
setShowAddPaymentMethodModal(!showAddPaymentMethodModal)
|
||||
}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
workspaceId={currentWorkspace.id}
|
||||
close={() => setShowAddPaymentMethodModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, useContext, useMemo, useReducer } from 'react';
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
|
||||
export interface UIContextState {
|
||||
paymentModal: boolean;
|
||||
/**
|
||||
* Determines whether or not the dashboard is in maintenance mode.
|
||||
*/
|
||||
@@ -12,42 +11,18 @@ export interface UIContextState {
|
||||
* The date and time when maintenance mode will end.
|
||||
*/
|
||||
maintenanceEndDate: Date;
|
||||
openPaymentModal: () => void;
|
||||
closePaymentModal: () => void;
|
||||
}
|
||||
|
||||
const initialState: UIContextState = {
|
||||
paymentModal: false,
|
||||
export const UIContext = createContext<UIContextState>({
|
||||
maintenanceActive: false,
|
||||
maintenanceEndDate: null,
|
||||
openPaymentModal: () => {},
|
||||
closePaymentModal: () => {},
|
||||
};
|
||||
|
||||
export const UIContext = createContext<UIContextState>(initialState);
|
||||
});
|
||||
|
||||
UIContext.displayName = 'UIContext';
|
||||
|
||||
function sideReducer(state: any, action: any) {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_PAYMENT_MODAL': {
|
||||
return {
|
||||
...state,
|
||||
paymentModal: !state.paymentModal,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
const [state, dispatch] = useReducer(sideReducer, initialState);
|
||||
const router = useRouter();
|
||||
|
||||
const openPaymentModal = () => dispatch({ type: 'TOGGLE_PAYMENT_MODAL' });
|
||||
const closePaymentModal = () => dispatch({ type: 'TOGGLE_PAYMENT_MODAL' });
|
||||
|
||||
const maintenanceUnlocked =
|
||||
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET &&
|
||||
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET ===
|
||||
@@ -55,9 +30,6 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
|
||||
const value: UIContextState = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
openPaymentModal,
|
||||
closePaymentModal,
|
||||
maintenanceActive: maintenanceUnlocked
|
||||
? false
|
||||
: process.env.NEXT_PUBLIC_MAINTENANCE_ACTIVE === 'true',
|
||||
@@ -67,7 +39,7 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
? new Date(Date.parse(process.env.NEXT_PUBLIC_MAINTENANCE_END_DATE))
|
||||
: null,
|
||||
}),
|
||||
[state, maintenanceUnlocked],
|
||||
[maintenanceUnlocked],
|
||||
);
|
||||
|
||||
return <UIContext.Provider value={value} {...props} />;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import calculateBillableResources from './calculateBillableResources';
|
||||
|
||||
test('should return zero if no services are provided', () => {
|
||||
expect(calculateBillableResources()).toMatchObject({ vcpu: 0, memory: 0 });
|
||||
});
|
||||
|
||||
test('should return the correct cost for a single service', () => {
|
||||
expect(
|
||||
calculateBillableResources({ replicas: 1, vcpu: 250, memory: 500 }),
|
||||
).toMatchObject({
|
||||
vcpu: 250,
|
||||
memory: 500,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return the correct cost for multiple services', () => {
|
||||
expect(
|
||||
calculateBillableResources(
|
||||
{ replicas: 1, vcpu: 250, memory: 250 },
|
||||
{ replicas: 1, vcpu: 250, memory: 500 },
|
||||
),
|
||||
).toMatchObject({ vcpu: 500, memory: 750 });
|
||||
});
|
||||
|
||||
test('should return the correct cost for multiple services with different vCPU and replica counts', () => {
|
||||
expect(
|
||||
calculateBillableResources(
|
||||
{ replicas: 2, vcpu: 250, memory: 500 },
|
||||
{ replicas: 1, vcpu: 500, memory: 750 },
|
||||
),
|
||||
).toMatchObject({ vcpu: 1000, memory: 1750 });
|
||||
});
|
||||
|
||||
test('should not count services with no replicas or vCPU and memory', () => {
|
||||
expect(
|
||||
calculateBillableResources(
|
||||
// should count
|
||||
{ replicas: 1, vcpu: 250 },
|
||||
// shouldn't count
|
||||
{ replicas: 1 },
|
||||
// shouldn't count
|
||||
{ vcpu: 250, memory: 1000 },
|
||||
// should count
|
||||
{ replicas: 1, memory: 500 },
|
||||
),
|
||||
).toMatchObject({ vcpu: 250, memory: 500 });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Calculate the approximate cost of a list of services.
|
||||
*
|
||||
* @param services - The list of services to calculate the cost of.
|
||||
* @returns The approximate cost of the services.
|
||||
*/
|
||||
export default function calculateBillableResources(
|
||||
...services: { replicas?: number; vcpu?: number; memory?: number }[]
|
||||
) {
|
||||
return services.reduce(
|
||||
(total, { replicas, vcpu, memory }) => {
|
||||
if (!replicas || (!vcpu && !memory)) {
|
||||
return total;
|
||||
}
|
||||
|
||||
if (!vcpu && memory) {
|
||||
return {
|
||||
...total,
|
||||
memory: total.memory + memory * replicas,
|
||||
};
|
||||
}
|
||||
|
||||
if (vcpu && !memory) {
|
||||
return {
|
||||
...total,
|
||||
vcpu: total.vcpu + vcpu * replicas,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
vcpu: total.vcpu + vcpu * replicas,
|
||||
memory: total.memory + memory * replicas,
|
||||
};
|
||||
},
|
||||
{ vcpu: 0, memory: 0 } as { vcpu: number; memory: number },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as calculateBillableResources } from './calculateBillableResources';
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'vitest';
|
||||
import getAllocatedResources from './getAllocatedResources';
|
||||
|
||||
test('should return the total number of allocated resources', () => {
|
||||
expect(
|
||||
getAllocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
hasura: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
auth: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
storage: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
}),
|
||||
).toEqual({ vcpu: 0, memory: 2 });
|
||||
|
||||
expect(
|
||||
getAllocatedResources({
|
||||
enabled: true,
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
hasura: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
auth: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
storage: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
).toEqual({ vcpu: 1, memory: 0 });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
|
||||
/**
|
||||
* Returns the allocated resources based on the form values.
|
||||
*
|
||||
* @param formValues - The form values.
|
||||
* @returns The allocated resources.
|
||||
*/
|
||||
export default function getAllocatedResources(
|
||||
formValues: Partial<ResourceSettingsFormValues>,
|
||||
) {
|
||||
return Object.keys(formValues).reduce(
|
||||
({ vcpu, memory }, currentKey) => {
|
||||
// Skip attributes that are not related to any of the services.
|
||||
if (
|
||||
typeof formValues[currentKey] !== 'object' ||
|
||||
!(
|
||||
'vcpu' in formValues[currentKey] && 'memory' in formValues[currentKey]
|
||||
)
|
||||
) {
|
||||
return { vcpu, memory };
|
||||
}
|
||||
|
||||
return {
|
||||
vcpu: vcpu + (formValues[currentKey].vcpu || 0),
|
||||
memory: memory + (formValues[currentKey].memory || 0),
|
||||
};
|
||||
},
|
||||
{
|
||||
vcpu: 0,
|
||||
memory: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as getAllocatedResources } from './getAllocatedResources';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as prettifyMemory } from './prettifyMemory';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RESOURCE_MEMORY_MULTIPLIER } from '@/utils/CONSTANTS';
|
||||
import { prettifyNumber } from '@/utils/common/prettifyNumber';
|
||||
|
||||
/**
|
||||
* Prettifies a number of memory.
|
||||
*
|
||||
* @param vcpu - The number of memory.
|
||||
* @returns The prettified number of memory.
|
||||
*/
|
||||
export default function prettifyMemory(memory: number) {
|
||||
return prettifyNumber(memory, {
|
||||
labels: ['MiB'],
|
||||
numberOfDecimals: 3,
|
||||
separator: ' ',
|
||||
multiplier: RESOURCE_MEMORY_MULTIPLIER,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as prettifyVCPU } from './prettifyVCPU';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/CONSTANTS';
|
||||
|
||||
/**
|
||||
* Prettifies a number of vCPUs.
|
||||
*
|
||||
* @param vcpu - The number of vCPUs.
|
||||
* @returns The prettified number of vCPUs.
|
||||
*/
|
||||
export default function prettifyVCPU(vcpu: number) {
|
||||
return vcpu / RESOURCE_VCPU_MULTIPLIER;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './resourceSettingsValidationSchema';
|
||||
@@ -0,0 +1,137 @@
|
||||
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 replicas that has to be allocated per service.
|
||||
*/
|
||||
export const MIN_SERVICE_REPLICAS = 1;
|
||||
|
||||
/**
|
||||
* The maximum amount of replicas that can be allocated per service.
|
||||
*/
|
||||
export const MAX_SERVICE_REPLICAS = 32;
|
||||
|
||||
/**
|
||||
* The minimum amount of CPU that has to be allocated per service.
|
||||
*/
|
||||
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;
|
||||
|
||||
const serviceValidationSchema = Yup.object({
|
||||
replicas: Yup.number()
|
||||
.label('Replicas')
|
||||
.required()
|
||||
.min(1)
|
||||
.max(MAX_SERVICE_REPLICAS)
|
||||
.test(
|
||||
'is-matching-ratio',
|
||||
`vCPU and Memory for this service must match the 1:${RESOURCE_VCPU_MEMORY_RATIO} ratio if more than one replica is selected.`,
|
||||
(replicas: number, { parent }) => {
|
||||
if (replicas === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parent.memory /
|
||||
RESOURCE_MEMORY_MULTIPLIER /
|
||||
(parent.vcpu / RESOURCE_VCPU_MULTIPLIER) ===
|
||||
RESOURCE_VCPU_MEMORY_RATIO
|
||||
);
|
||||
},
|
||||
),
|
||||
vcpu: Yup.number()
|
||||
.label('vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
memory: Yup.number()
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
});
|
||||
|
||||
export const resourceSettingsValidationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
totalAvailableVCPU: Yup.number()
|
||||
.label('Total Available vCPUs')
|
||||
.required()
|
||||
.min(MIN_TOTAL_VCPU)
|
||||
.max(MAX_TOTAL_VCPU)
|
||||
.test(
|
||||
'is-equal-to-services',
|
||||
'Total vCPUs must be equal to the sum of all services.',
|
||||
(totalAvailableVCPU: number, { parent }) =>
|
||||
parent.database.vcpu +
|
||||
parent.hasura.vcpu +
|
||||
parent.auth.vcpu +
|
||||
parent.storage.vcpu ===
|
||||
totalAvailableVCPU,
|
||||
),
|
||||
totalAvailableMemory: Yup.number()
|
||||
.label('Available Memory')
|
||||
.required()
|
||||
.min(MIN_TOTAL_MEMORY)
|
||||
.max(MAX_TOTAL_MEMORY)
|
||||
.test(
|
||||
'is-equal-to-services',
|
||||
'Total memory must be equal to the sum of all services.',
|
||||
(totalAvailableMemory: number, { parent }) =>
|
||||
parent.database.memory +
|
||||
parent.hasura.memory +
|
||||
parent.auth.memory +
|
||||
parent.storage.memory ===
|
||||
totalAvailableMemory,
|
||||
),
|
||||
database: serviceValidationSchema.required(),
|
||||
hasura: serviceValidationSchema.required(),
|
||||
auth: serviceValidationSchema.required(),
|
||||
storage: serviceValidationSchema.required(),
|
||||
});
|
||||
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
typeof resourceSettingsValidationSchema
|
||||
>;
|
||||
@@ -2,7 +2,4 @@ query GetWorkspaceAndProject($workspaceSlug: String!, $projectSlug: String) {
|
||||
workspaces(where: { slug: { _eq: $workspaceSlug } }) {
|
||||
...Workspace
|
||||
}
|
||||
projects: apps(where: { slug: { _eq: $projectSlug } }) {
|
||||
...Project
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
fragment ServiceResources on ConfigConfig {
|
||||
auth {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
storage {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetResources($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
...ServiceResources
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fragment Project on apps {
|
||||
plan {
|
||||
id
|
||||
name
|
||||
price
|
||||
isFree
|
||||
}
|
||||
githubRepository {
|
||||
|
||||
8
dashboard/src/gql/platform/getPlans.gql
Normal file
8
dashboard/src/gql/platform/getPlans.gql
Normal file
@@ -0,0 +1,8 @@
|
||||
query GetPlans($where: plans_bool_exp) {
|
||||
plans(where: $where) {
|
||||
id
|
||||
name
|
||||
isFree
|
||||
price
|
||||
}
|
||||
}
|
||||
1
dashboard/src/hooks/common/useProPlan/index.ts
Normal file
1
dashboard/src/hooks/common/useProPlan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useProPlan } from './useProPlan';
|
||||
16
dashboard/src/hooks/common/useProPlan/useProPlan.ts
Normal file
16
dashboard/src/hooks/common/useProPlan/useProPlan.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useGetPlansQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
export default function useProPlan() {
|
||||
const { data, ...rest } = useGetPlansQuery({
|
||||
variables: {
|
||||
where: {
|
||||
name: {
|
||||
_eq: 'Pro',
|
||||
},
|
||||
},
|
||||
},
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
return { data: data?.plans?.at(0), ...rest };
|
||||
}
|
||||
@@ -9,10 +9,10 @@ import { useMemo } from 'react';
|
||||
* @returns A function that returns a new ApolloClient instance.
|
||||
*/
|
||||
export function useRemoteApplicationGQLClient() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { currentProject, loading } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const userApplicationClient = useMemo(() => {
|
||||
if (!currentProject) {
|
||||
if (loading) {
|
||||
return new ApolloClient({ cache: new InMemoryCache() });
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ export function useRemoteApplicationGQLClient() {
|
||||
},
|
||||
}),
|
||||
});
|
||||
}, [currentProject]);
|
||||
}, [
|
||||
loading,
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region.awsName,
|
||||
currentProject?.config?.hasura.adminSecret,
|
||||
]);
|
||||
|
||||
return userApplicationClient;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { useNhostClient, useUserData } from '@nhost/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export interface UseCurrentWorkspaceAndProjectReturnType {
|
||||
/**
|
||||
@@ -48,14 +49,12 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery(
|
||||
['currentWorkspaceAndProject', workspaceSlug, appSlug],
|
||||
['currentWorkspaceAndProject', workspaceSlug],
|
||||
() =>
|
||||
client.graphql.request<{
|
||||
workspaces: Workspace[];
|
||||
projects?: Project[];
|
||||
}>(GetWorkspaceAndProjectDocument, {
|
||||
workspaceSlug: (workspaceSlug as string) || '',
|
||||
projectSlug: (appSlug as string) || '',
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
@@ -66,6 +65,18 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
},
|
||||
);
|
||||
|
||||
// Return the current workspace and project if using the Nhost backend
|
||||
const [currentWorkspace] = response?.data?.workspaces || [];
|
||||
const currentProject = useMemo(
|
||||
() =>
|
||||
appSlug
|
||||
? currentWorkspace?.projects?.find(
|
||||
(project) => project.slug === appSlug,
|
||||
)
|
||||
: null,
|
||||
[appSlug, currentWorkspace?.projects],
|
||||
);
|
||||
|
||||
// Return a default project if working locally
|
||||
if (!isPlatform) {
|
||||
const localProject: Project = {
|
||||
@@ -117,9 +128,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
};
|
||||
}
|
||||
|
||||
// Return the current workspace and project if using the Nhost backend
|
||||
const [currentWorkspace] = response?.data?.workspaces || [];
|
||||
const [currentProject] = response?.data?.projects || [];
|
||||
const error = Array.isArray(response?.error || {})
|
||||
? response?.error[0]
|
||||
: response?.error;
|
||||
|
||||
@@ -234,7 +234,6 @@ export default function SettingsGeneralPage() {
|
||||
disabled: maintenanceActive,
|
||||
onClick: () => {
|
||||
openDialog({
|
||||
title: '',
|
||||
component: (
|
||||
<RemoveApplicationModal
|
||||
close={closeDialog}
|
||||
@@ -243,7 +242,6 @@ export default function SettingsGeneralPage() {
|
||||
),
|
||||
props: {
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UnlockFeatureByUpgrading } from '@/components/applications/UnlockFeatureByUpgrading';
|
||||
import Container from '@/components/layout/Container';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import ResourcesForm from '@/components/settings/resources/ResourcesForm';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator/ActivityIndicator';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function ResourceSettingsPage() {
|
||||
const { currentProject, loading } = useCurrentWorkspaceAndProject();
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={1000} label="Loading project..." />;
|
||||
}
|
||||
|
||||
if (currentProject?.plan.isFree) {
|
||||
return (
|
||||
<UnlockFeatureByUpgrading message="Unlock Compute settings by upgrading your project to the Pro plan." />
|
||||
);
|
||||
}
|
||||
|
||||
return <ResourcesForm />;
|
||||
}
|
||||
|
||||
ResourceSettingsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-8 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
{page}
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
@@ -94,10 +94,7 @@ export default function SMTPSettingsPage() {
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<UnlockFeatureByUpgrading
|
||||
message="Unlock SMTP settings by upgrading your project to the Pro plan."
|
||||
className="mt-4"
|
||||
/>
|
||||
<UnlockFeatureByUpgrading message="Unlock SMTP settings by upgrading your project to the Pro plan." />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||
import Container from '@/components/layout/Container';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import features from '@/data/features.json';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -44,7 +44,7 @@ import type { ApolloError } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { FormEvent, ReactElement } from 'react';
|
||||
import { cloneElement, isValidElement, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import slugify from 'slugify';
|
||||
@@ -67,6 +67,7 @@ export function NewProjectPageContent({
|
||||
preSelectedWorkspace,
|
||||
preSelectedRegion,
|
||||
}: NewAppPageProps) {
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -102,11 +103,7 @@ export function NewProjectPageContent({
|
||||
|
||||
const [plan, setPlan] = useState(defaultSelectedPlan);
|
||||
|
||||
// state
|
||||
const { submitState, setSubmitState } = useSubmitState();
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
|
||||
// graphql mutations
|
||||
|
||||
const [insertApp] = useInsertApplicationMutation({
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
@@ -146,13 +143,8 @@ export function NewProjectPageContent({
|
||||
setDatabasePassword(newRandomDatabasePassword);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!plan.isFree && workspace.paymentMethods.length === 0) {
|
||||
setShowPaymentModal(true);
|
||||
return;
|
||||
}
|
||||
async function handleCreateProject(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setSubmitState({
|
||||
error: null,
|
||||
@@ -225,7 +217,29 @@ export function NewProjectPageContent({
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!plan.isFree && workspace.paymentMethods.length === 0) {
|
||||
openDialog({
|
||||
component: (
|
||||
<BillingPaymentMethodForm
|
||||
onPaymentMethodAdded={() => {
|
||||
handleCreateProject(event);
|
||||
closeDialog();
|
||||
}}
|
||||
workspaceId={workspace.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleCreateProject(event);
|
||||
}
|
||||
|
||||
if (!selectedWorkspace) {
|
||||
return (
|
||||
@@ -288,7 +302,13 @@ export function NewProjectPageContent({
|
||||
const workspaceInList = workspaces.find(
|
||||
({ id }) => id === value,
|
||||
);
|
||||
setPlan(plans[0]);
|
||||
|
||||
if (numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS) {
|
||||
setPlan(plans.find((currentPlan) => !currentPlan.isFree));
|
||||
} else {
|
||||
setPlan(plans[0]);
|
||||
}
|
||||
|
||||
setSelectedWorkspace({
|
||||
id: workspaceInList.id,
|
||||
name: workspaceInList.name,
|
||||
@@ -561,23 +581,6 @@ export function NewProjectPageContent({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{showPaymentModal && (
|
||||
<Modal
|
||||
showModal={showPaymentModal}
|
||||
close={() => {
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
close={() => {
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
onPaymentMethodAdded={handleSubmit}
|
||||
workspaceId={workspace.id}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={submitState.loading}
|
||||
@@ -597,7 +600,9 @@ export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const user = useUserData();
|
||||
|
||||
const { data, loading, error } = usePrefetchNewAppQuery();
|
||||
const { data, loading, error } = usePrefetchNewAppQuery({
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
const {
|
||||
data: freeAndActiveProjectsData,
|
||||
|
||||
107
dashboard/src/tests/mocks.ts
Normal file
107
dashboard/src/tests/mocks.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Project, Workspace } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { NhostSession } from '@nhost/nextjs';
|
||||
import type { NextRouter } from 'next/router';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const mockMatchMediaValue = (query: any) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
|
||||
export const mockRouter: NextRouter = {
|
||||
basePath: '',
|
||||
pathname: '/test-workspace/test-application',
|
||||
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,
|
||||
};
|
||||
|
||||
export const mockApplication: Project = {
|
||||
id: '1',
|
||||
name: 'Test Application',
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
awsName: 'us-east-1',
|
||||
city: 'New York',
|
||||
countryCode: 'US',
|
||||
id: '1',
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
deployments: [],
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
providersUpdated: true,
|
||||
githubRepository: { fullName: 'test/git-project' },
|
||||
repositoryProductionBranch: null,
|
||||
nhostBaseFolder: null,
|
||||
plan: {
|
||||
id: '1',
|
||||
name: 'Starter',
|
||||
isFree: true,
|
||||
price: 0,
|
||||
},
|
||||
config: {
|
||||
hasura: {
|
||||
adminSecret: 'nhost-admin-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockWorkspace: Workspace = {
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
slug: 'test-workspace',
|
||||
workspaceMembers: [],
|
||||
projects: [mockApplication],
|
||||
};
|
||||
|
||||
export const mockSession: NhostSession = {
|
||||
accessToken: faker.random.alphaNumeric(),
|
||||
accessTokenExpiresIn: 900,
|
||||
refreshToken: faker.datatype.uuid(),
|
||||
user: {
|
||||
id: faker.datatype.uuid(),
|
||||
email: faker.internet.email(),
|
||||
displayName: faker.name.fullName(),
|
||||
createdAt: faker.date.past().toISOString(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
locale: 'en',
|
||||
isAnonymous: false,
|
||||
defaultRole: 'user',
|
||||
roles: ['user', 'me'],
|
||||
metadata: {},
|
||||
emailVerified: true,
|
||||
phoneNumber: faker.phone.number(),
|
||||
phoneNumberVerified: true,
|
||||
activeMfaType: 'totp',
|
||||
},
|
||||
};
|
||||
66
dashboard/src/tests/msw/mocks/graphql/plansQuery.ts
Normal file
66
dashboard/src/tests/msw/mocks/graphql/plansQuery.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { mockApplication, mockWorkspace } from '@/tests/mocks';
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a query that returns only the Pro plan.
|
||||
*/
|
||||
export const getProPlanOnlyQuery = nhostGraphQLLink.query(
|
||||
'GetPlans',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
plans: [
|
||||
{
|
||||
__typename: 'plans',
|
||||
id: 'dc5e805e-1bef-4d43-809e-9fdf865e211a',
|
||||
name: 'Pro',
|
||||
price: 25,
|
||||
isFree: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a query that returns all the available plans.
|
||||
*/
|
||||
export const getAllPlansQuery = nhostGraphQLLink.query(
|
||||
'GetPlans',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
plans: [
|
||||
{
|
||||
__typename: 'plans',
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
name: 'Starter',
|
||||
price: 0,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
__typename: 'plans',
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'Pro',
|
||||
price: 25,
|
||||
isFree: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Use this handler to simulate a query that returns a workspace and a project.
|
||||
* Useful if you want to mock the currently selected project.
|
||||
*/
|
||||
export const getWorkspaceAndProjectQuery = nhostGraphQLLink.query(
|
||||
'GetWorkspaceAndProject',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
}),
|
||||
),
|
||||
);
|
||||
130
dashboard/src/tests/msw/mocks/graphql/resourceSettingsQuery.ts
Normal file
130
dashboard/src/tests/msw/mocks/graphql/resourceSettingsQuery.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2000,
|
||||
memory: 4096,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 2250,
|
||||
memory: 4608,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export default nhostGraphQLLink.mutation('UpdateConfig', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
updateConfig: {
|
||||
id: 'ConfigConfig',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -2,23 +2,28 @@
|
||||
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 type {
|
||||
Queries,
|
||||
RenderOptions,
|
||||
queries,
|
||||
waitForOptions,
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
render as rtlRender,
|
||||
waitForElementToBeRemoved as rtlWaitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
import { RouterContext } from 'next/dist/shared/lib/router-context';
|
||||
import type { 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 +41,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,8 +89,24 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForElementToBeRemoved<T>(
|
||||
callback: T | (() => T),
|
||||
options?: waitForOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await rtlWaitForElementToBeRemoved(callback, options);
|
||||
} catch {
|
||||
// We shouldn't fail if the element was to be removed but it wasn't there in
|
||||
// the first place.
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { render };
|
||||
export { render, waitForElementToBeRemoved };
|
||||
@@ -99,7 +99,7 @@ export default function getDesignTokens(mode: PaletteMode): PaletteOptions {
|
||||
dark: '#c91737',
|
||||
},
|
||||
warning: {
|
||||
light: '#ffebd3',
|
||||
light: 'rgba(255, 154, 35, 0.2)',
|
||||
main: '#ff9a23',
|
||||
dark: '#ed7200',
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { WorkspaceMembers } from '@/utils/__generated__/graphql';
|
||||
import type { Project } from './application';
|
||||
|
||||
export type Workspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
creatorUserId?: string;
|
||||
members: WorkspaceMembers[];
|
||||
applications: Project[];
|
||||
default?: boolean;
|
||||
};
|
||||
@@ -23,6 +23,45 @@ 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;
|
||||
|
||||
/**
|
||||
* Price per vCPU and 2 GiB of RAM per minute.
|
||||
*/
|
||||
export const RESOURCE_VCPU_PRICE_PER_MINUTE = 0.0012;
|
||||
|
||||
/**
|
||||
* Maximum number of free projects a user is allowed to have.
|
||||
*/
|
||||
|
||||
1457
dashboard/src/utils/__generated__/graphql.ts
generated
1457
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import features from '@/data/features.json';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getLocalBackendUrl } from '@/utils/env';
|
||||
import slugify from 'slugify';
|
||||
import type { DeploymentRowFragment } from './__generated__/graphql';
|
||||
|
||||
@@ -55,22 +54,6 @@ export function getCurrentEnvironment(): Environment {
|
||||
return (process.env.NEXT_PUBLIC_ENV || 'dev') as Environment;
|
||||
}
|
||||
|
||||
export function generateRemoteAppUrl(subdomain: string): string {
|
||||
if (process.env.NEXT_PUBLIC_NHOST_PLATFORM !== 'true') {
|
||||
return getLocalBackendUrl();
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return process.env.NEXT_PUBLIC_NHOST_BACKEND_URL;
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'staging') {
|
||||
return `https://${subdomain}.staging.nhost.run`;
|
||||
}
|
||||
|
||||
return `https://${subdomain}.nhost.run`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the state number of the application to its string equivalent.
|
||||
* @param appStatus The current state of the application.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"baseUrl": "./src",
|
||||
"useUnknownInCatchVariables": false,
|
||||
"paths": {
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/hooks/*": ["hooks/*"],
|
||||
@@ -32,7 +33,8 @@
|
||||
"@/theme/*": ["theme/*"],
|
||||
"@/generated/*": ["utils/__generated__/*"],
|
||||
"@/ui/*": ["components/ui/*"],
|
||||
"@/ui": ["components/ui/index.ts"]
|
||||
"@/ui": ["components/ui/index.ts"],
|
||||
"@/features/*": ["features/*"]
|
||||
},
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/hooks/*": ["hooks/*"],
|
||||
@@ -18,6 +19,7 @@
|
||||
"@/generated/*": ["utils/__generated__/*"],
|
||||
"@/ui/*": ["components/ui/*"],
|
||||
"@/ui": ["components/ui/index.ts"],
|
||||
"@/features/*": ["features/*"],
|
||||
"@nhost/nextjs": ["../../packages/nextjs/src/index.ts"],
|
||||
"@nhost/react-apollo": ["../../packages/react-apollo/src/index.ts"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
// @ts-ignore
|
||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] }), react()],
|
||||
test: {
|
||||
testTimeout: 5000,
|
||||
testTimeout: 20000,
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: 'src/setupTests.ts',
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f6639ae0: docs: add migration info
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d72ae3f3: Add section on Service Replicas
|
||||
|
||||
## 0.0.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -12,7 +12,7 @@ Nhost Authentication lets you authenticate users using different sign-in methods
|
||||
- [Email and Password](/authentication/sign-in-with-email-and-password)
|
||||
- [Magic Link](/authentication/sign-in-with-magic-link)
|
||||
- [Phone Number (SMS)](/authentication/sign-in-with-phone-number-sms)
|
||||
- [Security Keys (WebAuthn)](/authentication/sign-in-with-phone-number-sms)
|
||||
- [Security Keys (WebAuthn)](/authentication/sign-in-with-security-keys)
|
||||
- [Apple](/authentication/sign-in-with-apple)
|
||||
- [Discord](/authentication/sign-in-with-discord)
|
||||
- [Facebook](/authentication/sign-in-with-facebook)
|
||||
|
||||
@@ -95,6 +95,55 @@ const nhost = new NhostClient({
|
||||
})
|
||||
```
|
||||
|
||||
### Migration from `localhost` to `local`
|
||||
|
||||
`localhost` as the `subdomain` is deprecated and will be removed in the future.
|
||||
|
||||
Make sure you have the latest version of the CLI and the SDK installed:
|
||||
|
||||
```bash
|
||||
sudo nhost upgrade
|
||||
```
|
||||
|
||||
Install the latest version of the SDK:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="npm" label="npm" default>
|
||||
|
||||
```bash
|
||||
npm install @nhost/nhost-js@latest # or @nhost/react@latest / @nhost/nextjs@latest / @nhost/vue@latest
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yarn" label="Yarn">
|
||||
|
||||
```bash
|
||||
yarn add @nhost/nhost-js@latest # or @nhost/react@latest / @nhost/nextjs@latest / @nhost/vue@latest
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pnpm" label="pnpm">
|
||||
|
||||
```bash
|
||||
pnpm add @nhost/nhost-js@latest # or @nhost/react@latest / @nhost/nextjs@latest / @nhost/vue@latest
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Then change `localhost` to `local` in your code:
|
||||
|
||||
```js
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
// code-block-error-line
|
||||
- subdomain: 'localhost'
|
||||
// code-block-success-line
|
||||
+ subdomain: 'local'
|
||||
})
|
||||
```
|
||||
|
||||
## Emails
|
||||
|
||||
During local development with the CLI, all transactional emails from Nhost Authentication are sent to a local Mailhog services, instead of to the recipient's email address.
|
||||
|
||||
40
docs/docs/platform/compute.mdx
Normal file
40
docs/docs/platform/compute.mdx
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
|
||||
|
||||

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

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

|
||||
|
||||
For more information on compute resources and scaling your apps, refer to our documentation on [Compute Resources](https://docs.nhost.io/platform/compute).
|
||||
@@ -176,7 +176,17 @@ const config = {
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
defaultLanguage: 'javascript'
|
||||
defaultLanguage: 'javascript',
|
||||
magicComments: [
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
line: 'code-block-error-line'
|
||||
},
|
||||
{
|
||||
className: 'code-block-success-line',
|
||||
line: 'code-block-success-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
algolia: {
|
||||
appId: '3A3MJQTKHU',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.0.16",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
|
||||
@@ -270,3 +270,19 @@ h1 > code {
|
||||
font-size: 90%;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.code-block-error-line {
|
||||
background-color: #ff000020;
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||
padding: 0 var(--ifm-pre-padding);
|
||||
border-left: 3px solid #ff000080;
|
||||
}
|
||||
|
||||
.code-block-success-line {
|
||||
background-color: #00ff0020;
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||
padding: 0 var(--ifm-pre-padding);
|
||||
border-left: 3px solid #00ff0080;
|
||||
}
|
||||
|
||||
BIN
docs/static/img/platform/compute-resources/dashboard-slider.png
vendored
Normal file
BIN
docs/static/img/platform/compute-resources/dashboard-slider.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/static/img/platform/service-replicas/replicas-diagram.png
vendored
Normal file
BIN
docs/static/img/platform/service-replicas/replicas-diagram.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 475 KiB |
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user