Compare commits

..

200 Commits

Author SHA1 Message Date
Szilárd Dóró
925bf0f13f Merge pull request #1905 from nhost/changeset-release/main
chore: update versions
2023-05-08 13:51:55 +02:00
github-actions[bot]
30d35f9607 chore: update versions 2023-05-08 10:10:25 +00:00
Szilárd Dóró
755aa56f12 Merge pull request #1904 from nhost/fix/package-json-types
chore: add `types` to `package.json`
2023-05-08 12:09:11 +02:00
Szilárd Dóró
4c7e7c57a9 fix: don't break tests 2023-05-08 10:29:13 +02:00
Szilárd Dóró
36708e2853 Merge pull request #1903 from hrmoller/fix/wrong-linking-in-docs
Fixed linking to wrong destination in docs
2023-05-08 10:26:31 +02:00
Szilárd Dóró
90c6031189 chore: add types to package.json 2023-05-08 09:54:27 +02:00
Martin Møller
f044dbdb10 Fixed linking to wrong destination 2023-05-05 08:03:50 +02:00
Szilárd Dóró
c2f3bce5f9 Merge pull request #1902 from nhost/chore/probot-improvements
chore: refine probot config
2023-05-04 16:20:59 +02:00
Szilárd Dóró
22d9877b97 chore: update probot config 2023-05-04 16:04:09 +02:00
Szilárd Dóró
628e96dcc3 Merge pull request #1901 from nhost/chore/probot-stale
chore: add probot/stale configuration
2023-05-04 15:32:50 +02:00
Szilárd Dóró
3e9d3c42b6 fix: disable exemptLabels 2023-05-04 15:20:23 +02:00
Szilárd Dóró
a1e7b87c38 add probot/stale configuration 2023-05-04 15:08:37 +02:00
David Barroso
1bd800359e Merge pull request #1894 from nhost/dbarroso/obs-dash-improv
fix: observability: filter pod metrics
2023-05-03 12:59:07 +02:00
David Barroso
54a204a34e fix: observability: filter pod metrics 2023-05-03 10:09:27 +02:00
Szilárd Dóró
2e7ec0697e Merge pull request #1881 from nhost/changeset-release/main
chore: update versions
2023-05-02 21:06:46 +02:00
github-actions[bot]
2d9baec9d4 chore: update versions 2023-05-02 18:56:49 +00:00
Szilárd Dóró
7a7750be0b Merge pull request #1892 from nhost/fix/disable-downgrade
fix: disallow downgrading through the UI
2023-05-02 20:55:24 +02:00
Szilárd Dóró
0f34f0c6b9 fix: disallow downgrading 2023-05-02 15:31:39 +02:00
Nestor Manrique
d05253183a Merge pull request #1883 from nhost/nestor/fix-grafana-dashboard-datasource
fix: use dashboard externally exported version
2023-05-02 13:15:40 +02:00
Nestor Manrique
65df016bbc fix: fix datasource config 2023-04-28 17:28:43 +02:00
David Barroso
3e6ee1ae97 Merge pull request #1882 from nhost/dbarroso/observability-dashboard
feat: added project metrics observability dashboard
2023-04-28 14:32:55 +02:00
David Barroso
6042ed101f feat: added project metrics observability dashboard 2023-04-28 11:49:33 +02:00
Szilárd Dóró
384bce59bf Merge pull request #1859 from nhost/renovate/react-monorepo
chore(deps): update react monorepo
2023-04-28 09:59:24 +02:00
Szilárd Dóró
8da291ad4d chore: add changeset 2023-04-28 09:42:13 +02:00
renovate[bot]
f94eb3c467 chore(deps): update react monorepo 2023-04-27 18:07:47 +00:00
Szilárd Dóró
9baf3f4ac7 Merge pull request #1876 from nhost/changeset-release/main
chore: update versions
2023-04-27 20:00:27 +02:00
github-actions[bot]
9c406548e3 chore: update versions 2023-04-27 17:47:36 +00:00
Szilárd Dóró
1c08cd1949 Merge pull request #1878 from nhost/fix/local-users-page 2023-04-27 19:46:28 +02:00
Szilárd Dóró
adc828a582 fix: don't enter an infinite loop 2023-04-27 17:45:04 +02:00
Szilárd Dóró
f1ec6b9a93 Merge pull request #1871 from nhost/docs/local-development-migration
docs: add migration info
2023-04-27 16:37:40 +02:00
Szilárd Dóró
233b7e383e Merge pull request #1873 from nhost/changeset-release/main
chore: update versions
2023-04-27 16:06:05 +02:00
github-actions[bot]
7ea469a1e3 chore: update versions 2023-04-27 13:46:37 +00:00
Szilárd Dóró
ebd218c180 Merge pull request #1855 from nhost/feat/resource-replicas
feat(dashboard): Service Replicas
2023-04-27 15:45:31 +02:00
Nuno Pato
5ab1626f73 Merge pull request #1869 from nhost/docs/add-service-replicas
docs: add service replicas
2023-04-27 13:35:53 +00:00
Nuno Pato
444c3b86ca asd 2023-04-27 13:34:35 +00:00
Szilárd Dóró
7238412341 Merge pull request #1872 from nhost/chore/remove-backend-url
chore(dashboard): remove deprecated environment variable
2023-04-27 14:36:09 +02:00
Szilárd Dóró
f6639ae05c chore: add changeset 2023-04-27 13:54:27 +02:00
Szilárd Dóró
d8ceccec5d chore: add changeset 2023-04-27 13:41:11 +02:00
Szilárd Dóró
6db257d4c7 chore: remove deprecated backend URL 2023-04-27 13:40:41 +02:00
Szilárd Dóró
93dab2d183 docs: add migration info 2023-04-27 11:56:41 +02:00
Nuno Pato
dfc18368be asd 2023-04-26 18:09:42 +00:00
Nuno Pato
f7c6e80bf2 asd 2023-04-26 17:38:53 +00:00
Nuno Pato
573cac1431 asd 2023-04-26 17:23:11 +00:00
Nuno Pato
d72ae3f362 docs: add service replicas 2023-04-26 00:42:16 +00:00
Szilárd Dóró
49ec7ec385 fix: mobile improvements, improved validation 2023-04-25 15:30:38 +02:00
Szilárd Dóró
7d2b4083c2 chore: reorder components, update labels 2023-04-24 17:24:54 +02:00
Szilárd Dóró
696b493745 chore: increase test timeout 2023-04-24 16:23:51 +02:00
Szilárd Dóró
15a117a861 feat: improve cost calculation 2023-04-24 15:48:20 +02:00
Szilárd Dóró
e7ff1f79f8 feat: add information about replicas 2023-04-24 14:29:20 +02:00
Szilárd Dóró
33c7368a2e chore: update validation error message 2023-04-24 11:40:05 +02:00
Szilárd Dóró
664c182c8e fix: use font-medium for confirmation labels 2023-04-24 11:36:48 +02:00
Szilárd Dóró
c1ab4e0a77 chore: improve validation messages 2023-04-24 11:36:10 +02:00
Szilárd Dóró
4a4bd61757 chore: update tooltip label 2023-04-24 11:16:51 +02:00
Szilárd Dóró
b6d05289be fix: don't fail tests 2023-04-24 11:13:39 +02:00
Szilárd Dóró
5857458ca5 chore: improve resources form validation 2023-04-24 11:09:00 +02:00
Szilárd Dóró
2fb1145fe0 chore: add changeset 2023-04-24 10:22:44 +02:00
Szilárd Dóró
546d710102 Merge branch 'main' into feat/resource-replicas 2023-04-24 10:21:04 +02:00
Szilárd Dóró
7756103476 Merge pull request #1861 from nhost/changeset-release/main 2023-04-24 09:38:50 +02:00
github-actions[bot]
fef9456c12 chore: update versions 2023-04-23 19:36:24 +00:00
Szilárd Dóró
2d6d56f6b0 Merge pull request #1860 from nhost/fix/project-details
fix(dashboard): filter projects by workspace
2023-04-23 21:35:16 +02:00
Szilárd Dóró
f54be0fefd fix: don't break unit tests 2023-04-23 19:27:36 +02:00
Szilárd Dóró
4e76d388ab fix: remove unused query parameter 2023-04-23 16:42:33 +02:00
Szilárd Dóró
84b84ab785 fix: filter projects by workspace 2023-04-23 16:34:39 +02:00
Szilárd Dóró
ed66769688 Merge branch 'main' into feat/resource-replicas 2023-04-21 14:31:51 +02:00
Szilárd Dóró
899732f280 Merge pull request #1852 from nhost/changeset-release/main
chore: update versions
2023-04-21 13:40:09 +02:00
github-actions[bot]
037b566e39 chore: update versions 2023-04-21 11:23:53 +00:00
Szilárd Dóró
829f20c83c Merge pull request #1856 from nhost/renovate/vitejs-plugin-react-4.x
chore(deps): update dependency @vitejs/plugin-react to v4
2023-04-21 13:22:44 +02:00
Szilárd Dóró
f1b5a944a3 chore: add changeset 2023-04-21 11:42:44 +02:00
renovate[bot]
5ccb764ae5 chore(deps): update dependency @vitejs/plugin-react to v4 2023-04-21 09:38:43 +00:00
Szilárd Dóró
ef2b639734 Merge pull request #1839 from nhost/renovate/vueuse-core-10.x
fix(deps): update dependency @vueuse/core to v10
2023-04-21 11:36:00 +02:00
Szilárd Dóró
a5b895a827 chore: add changeset 2023-04-21 11:14:13 +02:00
renovate[bot]
b441b4bae2 fix(deps): update dependency @vueuse/core to v10 2023-04-21 09:02:30 +00:00
Szilárd Dóró
a6c67c1e4c Merge pull request #1836 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.37
2023-04-21 10:54:19 +02:00
Szilárd Dóró
7f1785ac0f chore: add changeset 2023-04-21 10:21:02 +02:00
Szilárd Dóró
a0298e0bdb chore: increase test timeout, improve stability 2023-04-20 16:40:42 +02:00
Szilárd Dóró
3fd94b1cdf chore: improve validation, fix tests 2023-04-20 16:08:37 +02:00
Szilárd Dóró
61d5f7d616 feat: make use of replicas from API 2023-04-20 14:56:52 +02:00
Szilárd Dóró
cde9a0a715 chore: extend tests, improve validation 2023-04-20 14:50:12 +02:00
Szilárd Dóró
eae6349b04 feat: add new pricing to confirmation dialog 2023-04-20 14:19:53 +02:00
Szilárd Dóró
211b930b84 chore: fix after effects of the new data structure 2023-04-20 13:53:06 +02:00
Szilárd Dóró
4ae463074b chore: simplify form data structure 2023-04-20 13:19:59 +02:00
Szilárd Dóró
1c5a4746f7 chore: improve validation error 2023-04-20 11:35:25 +02:00
Szilárd Dóró
d6ae1fa44a feat: resource validation when replicas > 1 2023-04-20 10:28:31 +02:00
Szilárd Dóró
a3abb81b37 feat: add replica slider to services 2023-04-19 15:57:57 +02:00
renovate[bot]
ec74e7fe98 chore(deps): update dependency @types/react to v18.0.37 2023-04-19 12:53:55 +00:00
Szilárd Dóró
6713c198c6 Merge pull request #1833 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.9.3
2023-04-19 14:52:03 +02:00
renovate[bot]
35a6b9cf47 chore(deps): update dependency turbo to v1.9.3 2023-04-19 09:32:19 +00:00
Szilárd Dóró
79f97fad76 Merge pull request #1838 from nhost/renovate/graphql-request-6.x
fix(deps): update dependency graphql-request to v6
2023-04-19 11:29:16 +02:00
Szilárd Dóró
2faf79077d chore: add changeset 2023-04-19 11:07:46 +02:00
Szilárd Dóró
4972b6feb6 chore: sync versions, update codegen 2023-04-19 11:07:15 +02:00
Szilárd Dóró
23d5861c4c Merge pull request #1837 from rikardwissing/fix/wait-for-valid-token
Wait for valid token or sign out before establishing connection.
2023-04-19 09:31:56 +02:00
renovate[bot]
098ac5a71c fix(deps): update dependency graphql-request to v6 2023-04-18 18:54:27 +00:00
Nuno Pato
3a15329cfd Merge pull request #1849 from nhost/docs/add-compute-section
add compute section to the docs
2023-04-18 13:20:39 +00:00
Nuno Pato
c3e798aa1d asd 2023-04-18 13:15:40 +00:00
Szilárd Dóró
eec5e6a93d Merge pull request #1843 from nhost/changeset-release/main
chore: update versions
2023-04-18 15:10:41 +02:00
Nuno Pato
d964b689cd move compute resources to position 1 2023-04-18 09:37:28 +00:00
Nuno Pato
1e080c1af5 add compute section to the docs 2023-04-18 09:35:01 +00:00
github-actions[bot]
177bba7ec0 chore: update versions 2023-04-18 07:27:56 +00:00
Szilárd Dóró
a593b45dc2 Merge pull request #1845 from nhost/chore/dashboard-update-nomenclature-compute
chore: dashboard: update nomenclature for compute
2023-04-18 09:24:46 +02:00
Szilárd Dóró
b384fb8bd8 chore: merge changesets 2023-04-17 20:58:38 +02:00
Nuno Pato
abd8620ded "Resources" -> "Compute" 2023-04-17 16:58:45 +00:00
Szilárd Dóró
e62ccdcaae Merge pull request #1844 from nhost/fix/resource-memory-limit
fix(dashboard): use correct vCPUs and memory after reset
2023-04-17 14:17:49 +02:00
Szilárd Dóró
46d01b09d6 chore: use constants 2023-04-17 13:45:59 +02:00
Szilárd Dóró
ff74e712f8 fix: use correct vCPUs and memory after reset 2023-04-17 13:38:47 +02:00
Szilárd Dóró
770794ccad Merge pull request #1709 from nhost/feat/resource-sliders
feat(dashboard): Resource Sliders
2023-04-17 13:03:04 +02:00
Szilárd Dóró
aa80d1795d chore: update initial resource ratio 2023-04-17 09:28:17 +02:00
Szilárd Dóró
eaa7720c65 fix: fix tests 2023-04-17 09:17:34 +02:00
Szilárd Dóró
7f447d1182 fix: don't break the initial UI 2023-04-17 08:43:23 +02:00
Szilárd Dóró
5d3dd84762 Merge branch 'main' into feat/resource-sliders 2023-04-17 08:36:55 +02:00
Szilárd Dóró
c625317342 Merge pull request #1841 from nhost/changeset-release/main
chore: update versions
2023-04-17 08:36:12 +02:00
Rikard Wissing
117398f5dc Add changeset 2023-04-16 15:00:50 +01:00
Rikard Wissing
4e421eb4bd Refactor a bit 2023-04-16 14:55:51 +01:00
Rikard Wissing
771447b089 Remove log 2023-04-16 14:52:35 +01:00
github-actions[bot]
8ab75a4146 chore: update versions 2023-04-14 09:54:44 +00:00
Szilárd Dóró
607f465616 Merge pull request #1840 from nhost/chore/use-dialog-hook
chore(dashboard): unify payment dialog management
2023-04-14 11:50:15 +02:00
Szilárd Dóró
668c877130 chore: add changeset 2023-04-14 11:17:32 +02:00
Szilárd Dóró
4bd870eb96 chore: relocate BillingPaymentMethodForm 2023-04-14 11:15:58 +02:00
Szilárd Dóró
39b3161d91 fix: use up-to-date card information 2023-04-14 10:31:27 +02:00
Szilárd Dóró
ae090a6585 chore: unify modal management for payments 2023-04-13 16:53:30 +02:00
Rikard Wissing
be4831ae62 Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-13 15:10:14 +02:00
Szilárd Dóró
4fb0c18c32 Merge branch 'main' into feat/resource-sliders 2023-04-13 14:56:03 +02:00
Szilárd Dóró
22cdd7f8d7 Merge pull request #1834 from nhost/changeset-release/main
chore: update versions
2023-04-13 14:38:10 +02:00
github-actions[bot]
f3a91a1f76 chore: update versions 2023-04-13 11:09:10 +00:00
Szilárd Dóró
1e9b92fcf8 Merge pull request #1835 from nhost/chore/remove-user-context
chore(dashboard): cleanup unused code
2023-04-13 13:07:30 +02:00
Szilárd Dóró
6cc56066c2 chore: update changeset 2023-04-13 11:44:38 +02:00
Rikard Wissing
99e80cea44 Wait for valid token 2023-04-12 23:45:38 +02:00
Szilárd Dóró
f2f1c01e3b chore: refactor memory steps 2023-04-12 17:43:44 +02:00
Szilárd Dóró
2c0f98e85c Merge branch 'main' into feat/resource-sliders 2023-04-12 14:43:12 +02:00
Szilárd Dóró
a3ad84925c chore: cleanup additional GraphQL operations 2023-04-12 14:36:11 +02:00
Szilárd Dóró
b8611b6a1c chore: cleanup unused GraphQL operations 2023-04-12 14:25:41 +02:00
Szilárd Dóró
a0e3030005 chore: cleanup UIContext 2023-04-12 14:01:41 +02:00
Szilárd Dóró
0cf1f1d938 Merge branch 'main' into chore/remove-user-context 2023-04-12 13:32:25 +02:00
Szilárd Dóró
88f026066f Merge pull request #1830 from rikardwissing/patch-1
Add generateLinks instead of link and onError args
2023-04-12 13:26:13 +02:00
Szilárd Dóró
185bef878d fix: accommodate dashboard test 2023-04-12 11:56:03 +02:00
Szilárd Dóró
a1c7b00e74 chore: add changeset 2023-04-12 11:55:29 +02:00
Szilárd Dóró
6da4562e79 chore: format code 2023-04-12 11:55:22 +02:00
Szilárd Dóró
e44cfcb2f2 Merge branch 'patch-1' of https://github.com/rikardwissing/nhost into pr/1830 2023-04-12 11:50:32 +02:00
Rikard Wissing
23fabaf8a6 Check for undefined 2023-04-12 11:48:48 +02:00
Szilárd Dóró
f4dca9836f fix: block UI when user is not available 2023-04-12 11:34:47 +02:00
Szilárd Dóró
f2704ea149 chore: improve query refetch 2023-04-12 11:30:34 +02:00
Szilárd Dóró
dd1b053212 fix: don't break provisioning page 2023-04-12 10:46:37 +02:00
Szilárd Dóró
d4ccc65655 chore: add changeset 2023-04-12 10:41:23 +02:00
Szilárd Dóró
2c2570fc82 chore: cleanup unused hooks 2023-04-12 10:40:49 +02:00
Rikard Wissing
a60f26966b Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-12 09:52:19 +02:00
Rikard Wissing
a988de2d61 Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-12 09:51:58 +02:00
Rikard Wissing
de54ca460e Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-12 09:51:48 +02:00
Szilárd Dóró
afdffab743 Merge pull request #1831 from nhost/fix/functions-response
fix(nhost-js): don't suppress error messages
2023-04-12 09:46:02 +02:00
Szilárd Dóró
4c61520397 chore: add changeset 2023-04-12 09:09:09 +02:00
Szilárd Dóró
f02cd444d5 fix: don't break builds 2023-04-11 17:38:10 +02:00
Szilárd Dóró
7f45a51aca fix: don't break build 2023-04-11 17:30:21 +02:00
Szilárd Dóró
08e70b9df9 fix: don't suppress error messages 2023-04-11 17:21:40 +02:00
Szilárd Dóró
20a83362ee fix: don't break builds 2023-04-11 15:28:23 +02:00
Szilárd Dóró
20b800c3e4 Merge branch 'main' into feat/resource-sliders 2023-04-11 15:24:28 +02:00
Szilárd Dóró
bfaa5b4c4a Merge branch 'main' into pr/1830 2023-04-11 14:57:39 +02:00
Rikard Wissing
a1a00b33ad Make backwards compatible
There is a behavioral change because of how customLink was implemented before. But I think this is the intended behavior.
2023-04-11 11:49:24 +02:00
Rikard Wissing
a269f4ca3f Add generateLinks instead of link and onError args
Breaking change, but makes it more versatile.
Not tested
2023-04-11 11:16:15 +02:00
Szilárd Dóró
baaa510309 fix: add price to GQL 2023-03-31 15:28:59 +02:00
Szilárd Dóró
a84aa5ad68 Merge branch 'main' into feat/resource-sliders 2023-03-31 15:19:09 +02:00
Szilárd Dóró
4191b933c9 Merge branch 'main' into feat/resource-sliders 2023-03-24 10:57:01 +01:00
Szilárd Dóró
cf2264ce1d Merge branch 'main' into feat/resource-sliders 2023-03-20 16:25:34 +01:00
Szilárd Dóró
02dd9dd8c0 Merge branch 'main' into feat/resource-sliders 2023-03-20 12:41:38 +01:00
Szilárd Dóró
d4ff25df0f fix: adjust warning color 2023-03-10 14:19:10 +01:00
Szilárd Dóró
3d74374780 fix: update codegen 2023-03-09 10:22:43 +01:00
Szilárd Dóró
7063af678c Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-09 10:14:33 +01:00
Szilárd Dóró
2b44a1cf27 chore: add tests, fix flaky tests
- fix a UI glitch where values jumped after reset
2023-03-08 16:33:02 +01:00
Szilárd Dóró
c4f60b3645 feat: fetch plan independently 2023-03-08 15:44:30 +01:00
Szilárd Dóró
f86f658aa5 feat: improve upgrade / downgrade experience 2023-03-08 13:54:35 +01:00
Szilárd Dóró
bd02bd3f3e fix: cpu -> vcpu and selected -> available 2023-03-08 13:41:50 +01:00
Szilárd Dóró
a133faa797 chore: added submission tests 2023-03-08 11:24:17 +01:00
Szilárd Dóró
bb0269691d Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-08 10:23:48 +01:00
Szilárd Dóró
8d6171d22d chore: move test-related stuff 2023-03-07 17:23:09 +01:00
Szilárd Dóró
fff178d79f fix: fix unit tests 2023-03-07 16:59:15 +01:00
Szilárd Dóró
5e5e454ae7 chore: remove inputs from total resources 2023-03-07 16:34:40 +01:00
Szilárd Dóró
ce005f6d9e Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-07 15:37:42 +01:00
Szilárd Dóró
85889ee882 chore: add changeset 2023-03-07 10:23:33 +01:00
Szilárd Dóró
351873059e fix: use correct colors in light mode 2023-03-07 10:09:00 +01:00
Szilárd Dóró
8ccfc10522 fix: fix inner slider behaviour 2023-03-07 10:05:25 +01:00
Szilárd Dóró
82b02ca70b feat: improve slider appearance 2023-03-07 09:12:28 +01:00
Szilárd Dóró
14fc132040 fix: show footer when downgrading 2023-03-06 17:02:54 +01:00
Szilárd Dóró
a35da349ed fix: fix tests and prevent error when plan is missing 2023-03-06 16:48:43 +01:00
Szilárd Dóró
302e1d9d33 feat: retrieve plan info from the project 2023-03-06 16:18:42 +01:00
Szilárd Dóró
0db40184e8 fix: show values correctly after saving resources 2023-03-06 15:38:07 +01:00
Szilárd Dóró
d38649494e chore: added tests 2023-03-06 15:14:30 +01:00
Szilárd Dóró
5f22f1b5e5 chore: restructure to support tests
- added network requests to retrieve computation info
2023-03-06 14:13:48 +01:00
Szilárd Dóró
494f93a4bf chore: change terminology RAM -> Memory 2023-03-06 10:28:40 +01:00
Szilárd Dóró
84c8af232c fix: don't nest p in p 2023-03-03 16:54:21 +01:00
Szilárd Dóró
7f101d54da feat: add pricing placeholders 2023-03-03 16:53:28 +01:00
Szilárd Dóró
75b497412e fix: reset error, fix RAM label 2023-03-03 16:16:09 +01:00
Szilárd Dóró
5bd774afbb chore: improve readability 2023-03-03 16:11:54 +01:00
Szilárd Dóró
cdfe203fe4 Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-03 15:37:45 +01:00
Szilárd Dóró
4c7d32e944 fix: resource labels 2023-03-03 15:35:17 +01:00
Szilárd Dóró
447c622fc0 fix: remove unnecessary inputs from services 2023-03-03 15:33:01 +01:00
Szilárd Dóró
03f22aed72 chore: improved resource constants 2023-03-03 15:21:15 +01:00
Szilárd Dóró
ede5abf2ac fix: use correct width for inner slider rail 2023-03-03 15:09:33 +01:00
Szilárd Dóró
0bdd1d0e0c fix: fixed top slider vlaidation 2023-03-03 14:19:08 +01:00
Szilárd Dóró
61de7b21fd feat: improve slider rails 2023-03-03 14:00:57 +01:00
Szilárd Dóró
4b6ead1b17 feat: add validation to top slider 2023-03-03 13:18:57 +01:00
Szilárd Dóró
0b193e6310 feat: create form fragments 2023-03-03 12:15:29 +01:00
Szilárd Dóró
b21a5403fe feat: add sections for services, improve slider 2023-03-03 11:15:01 +01:00
Szilárd Dóró
e8320be941 feat: initial Resource page code 2023-03-02 16:23:23 +01:00
177 changed files with 8798 additions and 3328 deletions

16
.github/stale.yml vendored Normal file
View 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.

View File

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

View File

@@ -1,5 +1,71 @@
# @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
- d4ccc656: chore: cleanup unused code
- @nhost/react-apollo@5.0.18
- @nhost/nextjs@1.13.21
## 0.14.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.14.6",
"version": "0.16.3",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -8,7 +8,7 @@
"build": "next build --no-lint",
"analyze": "ANALYZE=true pnpm build --no-lint",
"start": "next start",
"lint": "next lint --max-warnings 1",
"lint": "next lint --max-warnings 0",
"test": "vitest",
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
"nhost:dev": "nhost dev -d",
@@ -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": {

View File

@@ -1,6 +1,5 @@
import {
GetAllWorkspacesAndProjectsDocument,
GetOneUserDocument,
useDeleteApplicationMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
@@ -11,6 +10,7 @@ import Text from '@/ui/v2/Text';
import { copy } from '@/utils/copy';
import { getApplicationStatusString } from '@/utils/helpers';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { formatDistance } from 'date-fns';
import { useRouter } from 'next/router';
import { toast } from 'react-hot-toast';
@@ -18,7 +18,7 @@ import { toast } from 'react-hot-toast';
export default function ApplicationInfo() {
const { currentProject } = useCurrentWorkspaceAndProject();
const [deleteApplication] = useDeleteApplicationMutation({
refetchQueries: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
});
const router = useRouter();
@@ -37,6 +37,7 @@ export default function ApplicationInfo() {
'An error occurred while deleting the project. Please try again.',
),
},
getToastStyleProps(),
);
await router.push('/');
@@ -45,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">

View File

@@ -5,7 +5,6 @@ import { useDialog } from '@/components/common/DialogProvider';
import Container from '@/components/layout/Container';
import {
GetAllWorkspacesAndProjectsDocument,
GetOneUserDocument,
useGetFreeAndActiveProjectsQuery,
useUnpauseApplicationMutation,
} from '@/generated/graphql';
@@ -26,8 +25,12 @@ import { toast } from 'react-hot-toast';
import { RemoveApplicationModal } from './RemoveApplicationModal';
export default function ApplicationPaused() {
const { openAlertDialog } = useDialog();
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
const { openDialog } = useDialog();
const {
currentWorkspace,
currentProject,
refetch: refetchWorkspaceAndProject,
} = useCurrentWorkspaceAndProject();
const user = useUserData();
const isOwner = currentWorkspace.workspaceMembers.some(
({ id, type }) => id === user?.id && type === 'owner',
@@ -35,7 +38,7 @@ export default function ApplicationPaused() {
const [showDeletingModal, setShowDeletingModal] = useState(false);
const [unpauseApplication, { loading: changingApplicationStateLoading }] =
useUnpauseApplicationMutation({
refetchQueries: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
});
const { data, loading } = useGetFreeAndActiveProjectsQuery({
@@ -70,6 +73,8 @@ export default function ApplicationPaused() {
},
getToastStyleProps(),
);
await refetchWorkspaceAndProject();
} catch {
// Note: The toast will handle the error.
}
@@ -118,14 +123,10 @@ export default function ApplicationPaused() {
<Button
className="mx-auto w-full max-w-[280px]"
onClick={() => {
openAlertDialog({
title: 'Upgrade your plan.',
payload: <ChangePlanModal />,
openDialog({
component: <ChangePlanModal />,
props: {
PaperProps: { className: 'p-0' },
hidePrimaryAction: true,
hideSecondaryAction: true,
hideTitle: true,
maxWidth: 'lg',
},
});

View File

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

View File

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

View File

@@ -1,34 +1,28 @@
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,
useGetPaymentMethodsQuery,
useUpdateAppMutation,
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,16 +76,13 @@ 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] = useUpdateAppMutation({
const [updateApp] = useUpdateApplicationMutation({
refetchQueries: [
refetchGetApplicationPlanQuery({
workspace: currentWorkspace.slug,
@@ -98,28 +91,35 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
],
});
// function handlers
const handleUpdateAppPlan = async () => {
await updateApp({
variables: {
id: 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&apos;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} />;
}

View File

@@ -6,7 +6,6 @@ import Divider from '@/ui/v2/Divider';
import Text from '@/ui/v2/Text';
import {
GetAllWorkspacesAndProjectsDocument,
GetOneUserDocument,
useDeleteApplicationMutation,
} from '@/utils/__generated__/graphql';
import { discordAnnounce } from '@/utils/discordAnnounce';
@@ -47,7 +46,7 @@ export function RemoveApplicationModal({
className,
}: RemoveApplicationModalProps) {
const [deleteApplication] = useDeleteApplicationMutation({
refetchQueries: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
});
const [loadingRemove, setLoadingRemove] = useState(false);
const { currentProject } = useCurrentWorkspaceAndProject();

View File

@@ -19,7 +19,7 @@ export function UnlockFeatureByUpgrading({
className,
...props
}: UnlockFeatureByUpgradingProps) {
const { openAlertDialog } = useDialog();
const { openDialog } = useDialog();
return (
<div className={twMerge('flex', className)} {...props}>
@@ -29,14 +29,10 @@ export function UnlockFeatureByUpgrading({
<Button
variant="borderless"
onClick={() => {
openAlertDialog({
title: 'Upgrade your plan.',
payload: <ChangePlanModal />,
openDialog({
component: <ChangePlanModal />,
props: {
PaperProps: { className: 'p-0 max-w-xl w-full' },
hidePrimaryAction: true,
hideSecondaryAction: true,
hideTitle: true,
},
});
}}

View File

@@ -2,7 +2,7 @@ import type { EditRepositorySettingsFormData } from '@/components/applications/g
import { useDialog } from '@/components/common/DialogProvider';
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
import GithubIcon from '@/components/icons/GithubIcon';
import { useUpdateAppMutation } from '@/generated/graphql';
import { useUpdateApplicationMutation } from '@/generated/graphql';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
@@ -29,7 +29,7 @@ export function EditRepositorySettingsModal({
const { currentProject } = useCurrentWorkspaceAndProject();
const [updateApp, { loading }] = useUpdateAppMutation();
const [updateApp, { loading }] = useUpdateApplicationMutation();
const client = useApolloClient();
@@ -40,7 +40,7 @@ export function EditRepositorySettingsModal({
if (!currentProject.githubRepository || selectedRepoId) {
await updateApp({
variables: {
id: currentProject.id,
appId: currentProject.id,
app: {
githubRepositoryId: selectedRepoId,
repositoryProductionBranch: data.productionBranch,
@@ -51,7 +51,7 @@ export function EditRepositorySettingsModal({
} else {
await updateApp({
variables: {
id: currentProject.id,
appId: currentProject.id,
app: {
repositoryProductionBranch: data.productionBranch,
nhostBaseFolder: data.repoBaseFolder,

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@/utils/testUtils';
import { render, screen } from '@/tests/testUtils';
import type { Column } from 'react-table';
import { expect, test } from 'vitest';
import DataGrid from './DataGrid';

View File

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

View File

@@ -22,6 +22,13 @@ export function CountrySelector({ value, onChange }: CountrySelectorProps) {
value={value || null}
onChange={(_event, inputValue) => onChange(inputValue as string)}
placeholder="Select Country"
slotProps={{
listbox: { className: 'min-w-0 w-full' },
popper: {
disablePortal: false,
className: 'z-[10000] w-[270px] w-full',
},
}}
>
{countries?.map((country) => (
<Option key={country.name} value={country.code}>

View File

@@ -1,8 +1,8 @@
import Form from '@/components/common/Form';
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import type { ComponentMeta, ComponentStory } from '@storybook/react';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';

View File

@@ -1,7 +1,7 @@
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import { render, screen } from '@/utils/testUtils';
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
import { render, screen } from '@/tests/testUtils';
import { setupServer } from 'msw/node';
import { test, vi } from 'vitest';
import ColumnAutocomplete from './ColumnAutocomplete';

View File

@@ -1,10 +1,10 @@
import Form from '@/components/common/Form';
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
import type { RuleGroup } from '@/types/dataBrowser';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import type { ComponentMeta, ComponentStory } from '@storybook/react';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@/tests/testUtils';
import type { Deployment } from '@/types/application';
import { render, screen } from '@/utils/testUtils';
import { test, vi } from 'vitest';
import DeploymentStatusMessage from './DeploymentStatusMessage';

View File

@@ -3,14 +3,14 @@ import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { slugifyString } from '@/utils/helpers';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
refetchGetOneUserQuery,
GetAllWorkspacesAndProjectsDocument,
useInsertWorkspaceMutation,
useUpdateWorkspaceMutation,
} from '@/utils/__generated__/graphql';
import { slugifyString } from '@/utils/helpers';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router';
@@ -85,11 +85,7 @@ export default function EditWorkspaceNameForm({
const currentUser = useUserData();
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
refetchQueries: [
refetchGetOneUserQuery({
userId: currentUser.id,
}),
],
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
awaitRefetchQueries: true,
ignoreResults: true,
});
@@ -196,7 +192,7 @@ export default function EditWorkspaceNameForm({
}
await client.refetchQueries({
include: ['getOneUser'],
include: [GetAllWorkspacesAndProjectsDocument],
});
// The form has been submitted, it's not dirty anymore

View File

@@ -1,4 +1,8 @@
import { useGetWorkspaceMemberInvitesToManageQuery } from '@/generated/graphql';
import {
GetAllWorkspacesAndProjectsDocument,
GetWorkspaceMemberInvitesToManageDocument,
useGetWorkspaceMemberInvitesToManageQuery,
} from '@/generated/graphql';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useSubmitState } from '@/hooks/useSubmitState';
import Box from '@/ui/v2/Box';
@@ -114,7 +118,10 @@ export function InviteAnnounce() {
// just refetch all data
await client.refetchQueries({
include: ['getOneUser', 'getWorkspaceMemberInvitesToManage'],
include: [
GetAllWorkspacesAndProjectsDocument,
GetWorkspaceMemberInvitesToManageDocument,
],
});
setIgnoreState({

View File

@@ -4,7 +4,6 @@ import type { AuthenticatedLayoutProps } from '@/components/layout/Authenticated
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import useProjectRoutes from '@/hooks/common/useProjectRoutes';
import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWorkspacesAndApplications';
import { useNavigationVisible } from '@/hooks/useNavigationVisible';
import useNotFoundRedirect from '@/hooks/useNotFoundRedirect';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
@@ -47,7 +46,6 @@ function ProjectLayoutContent({
),
);
useGetAllUserWorkspacesAndApplications(false);
useNotFoundRedirect();
useEffect(() => {
@@ -105,7 +103,7 @@ function ProjectLayoutContent({
>
{children}
<NextSeo title={currentProject.name} />
<NextSeo title={currentProject?.name} />
</Box>
</>
);

View File

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

View File

@@ -15,7 +15,7 @@ export default function OverviewTopBar() {
const isPlatform = useIsPlatform();
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
const isPro = !currentProject?.plan?.isFree;
const { openAlertDialog } = useDialog();
const { openDialog } = useDialog();
const { maintenanceActive } = useUI();
if (!isPlatform) {
@@ -92,14 +92,10 @@ export default function OverviewTopBar() {
variant="borderless"
className="mr-2"
onClick={() => {
openAlertDialog({
title: 'Upgrade your plan.',
payload: <ChangePlanModal />,
openDialog({
component: <ChangePlanModal />,
props: {
PaperProps: { className: 'p-0 max-w-xl w-full' },
hidePrimaryAction: true,
hideSecondaryAction: true,
hideTitle: true,
},
});
}}

View File

@@ -39,12 +39,6 @@ export interface SettingsContainerProps
* @default 'https://docs.nhost.io/'
*/
docsLink?: string;
/**
* Props for the primary action.
*
* @deprecated Use `slotProps.submitButton` instead.
*/
primaryActionButtonProps?: ButtonProps;
/**
* Submit button text.
*
@@ -106,7 +100,6 @@ export default function SettingsContainer({
title,
description,
icon,
primaryActionButtonProps,
submitButtonText = 'Save',
className,
onEnabledChange,
@@ -188,18 +181,10 @@ export default function SettingsContainer({
)}
<Button
variant={
(submitButton || primaryActionButtonProps)?.disabled
? 'outlined'
: 'contained'
}
color={
(submitButton || primaryActionButtonProps)?.disabled
? 'secondary'
: 'primary'
}
variant={submitButton?.disabled ? 'outlined' : 'contained'}
color={submitButton?.disabled ? 'secondary' : 'primary'}
type="submit"
{...(submitButton || primaryActionButtonProps)}
{...submitButton}
>
{submitButtonText}
</Button>

View File

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

View File

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

View File

@@ -2,7 +2,10 @@ import Form from '@/components/common/Form';
import InlineCode from '@/components/common/InlineCode';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useUpdateAppMutation } from '@/generated/graphql';
import {
GetAllWorkspacesAndProjectsDocument,
useUpdateApplicationMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import { Alert } from '@/ui/Alert';
import Input from '@/ui/v2/Input';
@@ -24,7 +27,7 @@ export interface BaseDirectoryFormValues {
export default function BaseDirectorySettings() {
const { maintenanceActive } = useUI();
const { currentProject } = useCurrentWorkspaceAndProject();
const [updateApp] = useUpdateAppMutation();
const [updateApp] = useUpdateApplicationMutation();
const client = useApolloClient();
const form = useForm<BaseDirectoryFormValues>({
@@ -45,7 +48,7 @@ export default function BaseDirectorySettings() {
const handleBaseFolderChange = async (values: BaseDirectoryFormValues) => {
const updateAppMutation = updateApp({
variables: {
id: currentProject.id,
appId: currentProject.id,
app: {
...values,
},
@@ -67,7 +70,9 @@ export default function BaseDirectorySettings() {
form.reset(values);
try {
await client.refetchQueries({ include: ['getOneUser'] });
await client.refetchQueries({
include: [GetAllWorkspacesAndProjectsDocument],
});
} catch (error) {
await discordAnnounce(
error.message || 'Error while trying to update application cache',

View File

@@ -1,7 +1,10 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useUpdateAppMutation } from '@/generated/graphql';
import {
GetAllWorkspacesAndProjectsDocument,
useUpdateApplicationMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import { Alert } from '@/ui/Alert';
import Input from '@/ui/v2/Input';
@@ -23,7 +26,7 @@ export interface DeploymentBranchFormValues {
export default function DeploymentBranchSettings() {
const { maintenanceActive } = useUI();
const { currentProject } = useCurrentWorkspaceAndProject();
const [updateApp] = useUpdateAppMutation();
const [updateApp] = useUpdateApplicationMutation();
const client = useApolloClient();
const form = useForm<DeploymentBranchFormValues>({
@@ -46,7 +49,7 @@ export default function DeploymentBranchSettings() {
) => {
const updateAppMutation = updateApp({
variables: {
id: currentProject.id,
appId: currentProject.id,
app: {
...values,
},
@@ -68,7 +71,9 @@ export default function DeploymentBranchSettings() {
form.reset(values);
try {
await client.refetchQueries({ include: ['getOneUser'] });
await client.refetchQueries({
include: [GetAllWorkspacesAndProjectsDocument],
});
} catch (error) {
await discordAnnounce(
error.message || 'Error while trying to update application cache',

View File

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

View File

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

View File

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

View File

@@ -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&apos;t load the plan for this project. Please try again.
</Alert>
);
}
if (loading || proPlanLoading) {
return (
<ActivityIndicator
label="Loading resource settings..."
delay={1000}
className="mx-auto"
/>
);
}
const { watch, formState } = form;
const isDirty = Object.keys(formState.dirtyFields).length > 0;
const 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
import { useDialog } from '@/components/common/DialogProvider';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text';
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useState } from 'react';
import toast from 'react-hot-toast';
export default function ProvidersUpdatedAlert() {
const { currentProject } = useCurrentWorkspaceAndProject();
const { openAlertDialog } = useDialog();
const [confirmed, setConfirmed] = useState(true);
const [confirmProvidersUpdated] = useConfirmProvidersUpdatedMutation({
variables: { id: currentProject?.id },
});
async function handleSubmitConfirmation() {
const confirmProvidersUpdatedPromise = confirmProvidersUpdated();
await toast.promise(
confirmProvidersUpdatedPromise,
{
loading: 'Confirming...',
success: 'Your settings have been updated successfully.',
error: getServerError(
'An error occurred while trying to confirm the message.',
),
},
getToastStyleProps(),
);
setConfirmed(false);
}
function handleOpenConfirmationDialog() {
openAlertDialog({
title: 'Confirm all providers updated?',
payload: (
<Text variant="subtitle1" component="span">
Please make sure to update all providers before continuing. Your
sign-in flows might break if you don&apos;t.
</Text>
),
props: {
onPrimaryAction: handleSubmitConfirmation,
},
});
}
if (!confirmed) {
return null;
}
return (
<Alert className="grid grid-flow-row place-items-center items-center gap-2 bg-amber-500 p-4 lg:grid-flow-col lg:place-content-between">
<div className="grid grid-flow-row gap-1 text-left">
<Text className="font-semibold">
Please update the Redirect URL for all providers being used
</Text>
<Text className="text-sm+">
We are deprecating your project&apos;s old DNS name in favor of
individual DNS names for each service. Please make sure to update your
providers to use the new auth specific URL under <b>Redirect URL</b>{' '}
before the 1st of February 2023.{' '}
<Link
href="https://github.com/nhost/nhost/discussions/1319"
target="_blank"
rel="noopener noreferrer"
underline="hover"
className="font-medium"
>
Read the discussion here.
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
</Link>
</Text>
</div>
<Button variant="borderless" onClick={handleOpenConfirmationDialog}>
I have updated all Redirect URLs
</Button>
</Alert>
);
}

View File

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

View File

@@ -21,7 +21,7 @@ export function Alert({
return (
<Box
className={twMerge(
'rounded-sm+ bg-opacity-20 p-2 text-center text-sm+',
'rounded-sm+ bg-opacity-20 p-4 text-center text-sm+ motion-safe:transition-colors',
className,
)}
sx={[
@@ -32,11 +32,11 @@ export function Alert({
},
severity === 'warning' && {
backgroundColor: 'warning.light',
color: 'warning.dark',
color: 'text.primary',
},
severity === 'success' && {
backgroundColor: 'success.light',
color: 'success.main',
color: 'success.dark',
},
severity === 'info' && { backgroundColor: 'primary.light' },
]}

View File

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

View File

@@ -141,6 +141,7 @@ const ContainedButton = forwardRef(
backgroundColor: 'error.dark',
},
'&:focus': {
backgroundColor: 'error.main',
boxShadow: (theme) =>
`0 0 0 2px ${alpha(theme.palette.error.main, 0.3)}`,
},

View File

@@ -22,7 +22,7 @@ function Dialog({
aria-describedby="dialog-description"
{...props}
>
{!hideTitle && (
{!hideTitle && !!title && (
<DialogTitle
sx={{
padding: (theme) => theme.spacing(3, 3, 1.5, 3),

View File

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

View File

@@ -0,0 +1,81 @@
import { alpha, styled } from '@mui/material';
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
import MaterialSlider, {
sliderClasses as materialSliderClasses,
} from '@mui/material/Slider';
import type { ForwardedRef, PropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import SliderRail from './SliderRail';
export interface SliderProps
extends PropsWithoutRef<Omit<MaterialSliderProps, 'color'>> {
/**
* The maximum allowed value of the slider. The rail will be colored up to
* this value.
*/
allowed?: number;
}
const StyledSlider = styled(MaterialSlider)(({ theme }) => ({
color: theme.palette.primary.main,
[`& .${materialSliderClasses.mark}`]: {
height: 6,
width: 1,
backgroundColor: theme.palette.grey[400],
},
[`& .${materialSliderClasses.rail}`]: {
opacity: 1,
backgroundColor: theme.palette.grey[200],
height: 6,
},
[`& .${materialSliderClasses.markActive}`]: {
opacity: 0,
},
[`& .${materialSliderClasses.track}`]: {
backgroundColor: theme.palette.primary.main,
height: 6,
},
[`& .${materialSliderClasses.thumb}`]: {
width: 16,
height: 16,
'&:before': {
boxShadow: 'none',
},
},
[`& .${materialSliderClasses.thumbColorPrimary}`]: {
backgroundColor: theme.palette.primary.main,
[`&:focus, &:hover, &.${materialSliderClasses.active}, &.${materialSliderClasses.focusVisible}`]:
{
boxShadow: `0 0 0 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);

View File

@@ -0,0 +1,69 @@
import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box';
import { alpha, styled } from '@mui/material';
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
import MaterialSlider, {
sliderClasses as materialSliderClasses,
} from '@mui/material/Slider';
const StyledRail = styled(Box)(({ theme }) => ({
position: 'absolute',
display: 'block',
opacity: 1,
backgroundColor: theme.palette.grey[200],
height: 8,
top: '50%',
width: '100%',
borderRadius: 3,
transform: 'translateY(-50%)',
overflow: 'hidden',
}));
const StyledInnerSlider = styled(MaterialSlider)(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
height: 6,
padding: 0,
color: theme.palette.primary.main,
[`& .${materialSliderClasses.rail}`]: {
height: 6,
opacity: 1,
background: theme.palette.grey[200],
},
[`& .${materialSliderClasses.track}`]: {
borderRadius: 0,
border: 'none',
height: 6,
backgroundColor:
theme.palette.mode === 'light'
? alpha(theme.palette.primary.main, 0.1)
: alpha(theme.palette.primary.main, 0.15),
},
[`& .${materialSliderClasses.markActive}`]: {
backgroundColor: alpha(theme.palette.primary.main, 0.5),
opacity: 1,
},
}));
export interface SliderRailProps extends MaterialSliderProps {}
export default function SliderRail({
value,
...railAttributes
}: SliderRailProps) {
return function Rail(props: BoxProps) {
return (
<StyledRail component="span" {...props}>
{value > 0 && (
<StyledInnerSlider
{...railAttributes}
value={value}
disabled
components={{ Thumb: () => null }}
/>
)}
</StyledRail>
);
};
}

View File

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

View File

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

View File

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

View File

@@ -27,13 +27,11 @@ const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PK
: null;
type AddPaymentMethodFormProps = {
close: () => void;
onPaymentMethodAdded?: () => Promise<void>;
onPaymentMethodAdded?: () => void;
workspaceId: string;
};
function AddPaymentMethodForm({
close,
onPaymentMethodAdded,
workspaceId,
}: AddPaymentMethodFormProps) {
@@ -141,9 +139,7 @@ function AddPaymentMethodForm({
// payment method added successfylly
triggerToast(`New payment method added`);
close();
triggerToast('New payment method has been added to the workspace.');
discordAnnounce(
`(${user.email}) added a new credit card to workspace id: ${workspaceId}.`,
@@ -205,26 +201,27 @@ function AddPaymentMethodForm({
);
}
type BillingPaymentMethodFormProps = {
close: () => void;
onPaymentMethodAdded?: (e?: any) => Promise<void>;
export interface BillingPaymentMethodFormProps {
/**
* Callback function to run after a payment method is added.
*/
onPaymentMethodAdded?: (e?: any) => void;
/**
* Workspace identifier.
*/
workspaceId: string;
};
}
export function BillingPaymentMethodForm({
close,
export default function BillingPaymentMethodForm({
onPaymentMethodAdded,
workspaceId,
}: BillingPaymentMethodFormProps) {
return (
<Elements stripe={stripePromise}>
<AddPaymentMethodForm
close={close}
onPaymentMethodAdded={onPaymentMethodAdded}
workspaceId={workspaceId}
/>
</Elements>
);
}
export default BillingPaymentMethodForm;

View File

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

View File

@@ -1,19 +1,35 @@
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import { Alert } from '@/ui/Alert';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
import Checkbox from '@/ui/v2/Checkbox';
import Text from '@/ui/v2/Text';
import { useDeleteWorkspaceMutation } from '@/utils/__generated__/graphql';
import {
GetAllWorkspacesAndProjectsDocument,
useDeleteWorkspaceMutation,
} from '@/utils/__generated__/graphql';
import { getErrorMessage } from '@/utils/getErrorMessage';
import { triggerToast } from '@/utils/toast';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import router from 'next/router';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
export default function RemoveWorkspaceModal() {
export interface RemoveWorkspaceModalProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
}
export default function RemoveWorkspaceModal({
onSubmit,
onCancel,
}: RemoveWorkspaceModalProps) {
const [remove, setRemove] = useState(false);
const { closeDeleteWorkspaceModal } = useUI();
const [deleteWorkspace, { loading, error: mutationError, client }] =
useDeleteWorkspaceMutation();
@@ -22,66 +38,63 @@ export default function RemoveWorkspaceModal() {
async function handleClick() {
try {
await deleteWorkspace({
variables: {
id: currentWorkspace.id,
await toast.promise(
deleteWorkspace({
variables: {
id: currentWorkspace.id,
},
}),
{
loading: 'Deleting workspace...',
success: `Workspace "${currentWorkspace.name}" has been deleted successfully.`,
error: getServerError(
`An error occurred while trying to delete the workspace "${currentWorkspace.name}". Please try again.`,
),
},
});
triggerToast(`Workspace ${currentWorkspace.name} successfully deleted`);
closeDeleteWorkspaceModal();
getToastStyleProps(),
);
} catch (error) {
// TODO: Display error to user and use a logging solution
return;
}
await onSubmit?.();
await router.push('/');
await client.refetchQueries({ include: ['getOneUser'] });
await client.refetchQueries({
include: [GetAllWorkspacesAndProjectsDocument],
});
}
return (
<Box className="w-modal rounded-lg p-6 text-left">
<div className="grid grid-flow-row gap-4">
<div className="grid grid-flow-row gap-1">
<Text variant="h3" component="h2">
Delete Workspace
</Text>
<Box className="grid grid-flow-row gap-4 px-6 pt-4 pb-6">
<Box className="border-y py-2">
<Checkbox
id="accept-remove"
label={`I'm sure I want to delete ${currentWorkspace.name}`}
className="py-2"
checked={remove}
onChange={(_event, checked) => setRemove(checked)}
aria-label="Confirm Delete Workspace"
/>
</Box>
<Text>There is no way to recover this workspace later.</Text>
</div>
<div className="grid grid-flow-row gap-2">
{mutationError && (
<Alert severity="error">{getErrorMessage(mutationError)}</Alert>
)}
<Box className="border-y py-2">
<Checkbox
id="accept-remove"
label={`I'm sure I want to delete ${currentWorkspace.name}`}
className="py-2"
checked={remove}
onChange={(_event, checked) => setRemove(checked)}
aria-label="Confirm Delete Workspace"
/>
</Box>
<Button
color="error"
onClick={handleClick}
disabled={!remove || !!mutationError}
className=""
loading={loading}
>
Delete
</Button>
<div className="grid grid-flow-row gap-2">
{mutationError && (
<Alert severity="error">{getErrorMessage(mutationError)}</Alert>
)}
<Button
color="error"
onClick={handleClick}
disabled={!remove || !!mutationError}
className=""
loading={loading}
>
Delete Workspace
</Button>
<Button
variant="outlined"
color="secondary"
onClick={closeDeleteWorkspaceModal}
>
Cancel
</Button>
</div>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Box>
);

View File

@@ -1,10 +1,8 @@
import { useDialog } from '@/components/common/DialogProvider';
import { EditWorkspaceNameForm } from '@/components/home/EditWorkspaceNameForm';
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import { Avatar } from '@/ui/Avatar';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
@@ -16,12 +14,6 @@ import Image from 'next/image';
export default function WorkspaceHeader() {
const { currentWorkspace } = useCurrentWorkspaceAndProject();
const {
openDeleteWorkspaceModal,
closeDeleteWorkspaceModal,
deleteWorkspaceModal,
} = useUI();
const { openDialog } = useDialog();
const user = nhost.auth.getUser();
@@ -36,11 +28,6 @@ export default function WorkspaceHeader() {
return (
<div className="mx-auto flex max-w-3xl flex-col">
<Modal
showModal={deleteWorkspaceModal}
close={closeDeleteWorkspaceModal}
Component={RemoveWorkspaceModal}
/>
<div className="flex flex-row place-content-between">
<div className="flex flex-row items-center">
{IS_DEFAULT_WORKSPACE &&
@@ -98,7 +85,7 @@ export default function WorkspaceHeader() {
</Dropdown.Trigger>
<Dropdown.Content
PaperProps={{ className: 'mt-1 w-[280px]' }}
PaperProps={{ className: 'mt-1 max-w-[280px]' }}
menu
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
@@ -125,7 +112,7 @@ export default function WorkspaceHeader() {
});
}}
>
Change workspace name
Change Workspace Name
</Dropdown.Item>
<Divider component="li" sx={{ margin: 0 }} />
@@ -133,18 +120,34 @@ export default function WorkspaceHeader() {
<Dropdown.Item
className="grid grid-flow-row whitespace-pre-wrap py-2 font-medium"
disabled={!noApplications}
onClick={openDeleteWorkspaceModal}
onClick={() =>
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Delete Workspace</span>
<Text variant="subtitle1" component="span">
There is no way to recover this workspace later.
</Text>
</span>
),
component: <RemoveWorkspaceModal />,
props: {
titleProps: { className: '!pb-0' },
},
})
}
sx={{ color: 'error.main' }}
>
I want to remove this workspace
Delete Workspace
{!noApplications && (
<Text
variant="caption"
className="font-medium"
color="disabled"
>
You can&apos;t remove this workspace because you have apps
running. Remove all apps first.
You can&apos;t delete this workspace because you have
projects running. Delete all projects first.
</Text>
)}
</Dropdown.Item>

View File

@@ -1,5 +1,5 @@
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
import { useDialog } from '@/components/common/DialogProvider';
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
import type { GetPaymentMethodsFragment } from '@/generated/graphql';
import {
refetchGetPaymentMethodsQuery,
@@ -8,7 +8,6 @@ import {
useSetNewDefaultPaymentMethodMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import { Modal } from '@/ui/Modal';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Table from '@/ui/v2/Table';
@@ -21,8 +20,6 @@ import Text from '@/ui/v2/Text';
import { triggerToast } from '@/utils/toast';
import { useTheme } from '@mui/material';
import { formatDistanceToNowStrict } from 'date-fns';
import { useRouter } from 'next/router';
import { useState } from 'react';
function CheckCircle() {
const theme = useTheme();
@@ -44,15 +41,8 @@ function CheckCircle() {
}
export default function WorkspacePaymentMethods() {
const router = useRouter();
const { action } = router.query;
const { currentWorkspace } = useCurrentWorkspaceAndProject();
const { openAlertDialog } = useDialog();
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(
action === 'add-payment-method',
);
const { openAlertDialog, openDialog, closeDialog } = useDialog();
const { loading, error, data } = useGetPaymentMethodsQuery({
variables: {
@@ -230,7 +220,14 @@ export default function WorkspacePaymentMethods() {
<Button
variant="outlined"
onClick={() => {
setShowAddPaymentMethodModal(true);
openDialog({
component: (
<BillingPaymentMethodForm
workspaceId={currentWorkspace.id}
onPaymentMethodAdded={closeDialog}
/>
),
});
}}
disabled={maxPaymentMethodsReached}
>
@@ -244,19 +241,6 @@ export default function WorkspacePaymentMethods() {
payment methods.
</Text>
)}
{showAddPaymentMethodModal && (
<Modal
showModal={showAddPaymentMethodModal}
close={() =>
setShowAddPaymentMethodModal(!showAddPaymentMethodModal)
}
>
<BillingPaymentMethodForm
workspaceId={currentWorkspace.id}
close={() => setShowAddPaymentMethodModal(false)}
/>
</Modal>
)}
</div>
</div>
</div>

View File

@@ -1,14 +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 {
newWorkspace: boolean;
modal: boolean;
deleteApplicationModal: boolean;
deleteWorkspaceModal: boolean;
resourcesCollapsible: boolean;
paymentModal: boolean;
/**
* Determines whether or not the dashboard is in maintenance mode.
*/
@@ -17,61 +11,18 @@ export interface UIContextState {
* The date and time when maintenance mode will end.
*/
maintenanceEndDate: Date;
openPaymentModal: () => void;
closePaymentModal: () => void;
openDeleteWorkspaceModal: () => void;
closeDeleteWorkspaceModal: () => void;
}
const initialState: UIContextState = {
newWorkspace: false,
modal: false,
deleteApplicationModal: false,
deleteWorkspaceModal: false,
resourcesCollapsible: true,
paymentModal: false,
export const UIContext = createContext<UIContextState>({
maintenanceActive: false,
maintenanceEndDate: null,
openPaymentModal: () => {},
closePaymentModal: () => {},
openDeleteWorkspaceModal: () => {},
closeDeleteWorkspaceModal: () => {},
};
export const UIContext = createContext<UIContextState>(initialState);
});
UIContext.displayName = 'UIContext';
function sideReducer(state: any, action: any) {
switch (action.type) {
case 'TOGGLE_DELETE_WORKSPACE_MODAL': {
return {
...state,
deleteWorkspaceModal: !state.deleteWorkspaceModal,
};
}
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 openDeleteWorkspaceModal = () =>
dispatch({ type: 'TOGGLE_DELETE_WORKSPACE_MODAL' });
const closeDeleteWorkspaceModal = () =>
dispatch({ type: 'TOGGLE_DELETE_WORKSPACE_MODAL' });
const maintenanceUnlocked =
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET &&
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET ===
@@ -79,11 +30,6 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
const value: UIContextState = useMemo(
() => ({
...state,
openDeleteWorkspaceModal,
closeDeleteWorkspaceModal,
openPaymentModal,
closePaymentModal,
maintenanceActive: maintenanceUnlocked
? false
: process.env.NEXT_PUBLIC_MAINTENANCE_ACTIVE === 'true',
@@ -93,7 +39,7 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
? new Date(Date.parse(process.env.NEXT_PUBLIC_MAINTENANCE_END_DATE))
: null,
}),
[state, maintenanceUnlocked],
[maintenanceUnlocked],
);
return <UIContext.Provider value={value} {...props} />;

View File

@@ -1,69 +0,0 @@
import type { Workspace } from '@/types/workspace';
import type { PropsWithChildren } from 'react';
import { createContext, useContext, useMemo, useState } from 'react';
type Metadata = {
lastWorkspace: string;
template?: string;
};
type UserContextData = {
workspaces: Workspace[];
metadata?: Metadata;
};
export type UserDataContent = {
userContext: UserContextData;
setUserContext: (d: UserContextData) => void;
};
export const UserDataContext = createContext<UserDataContent>({
userContext: {
workspaces: [],
metadata: { lastWorkspace: '' },
},
setUserContext: () => {},
});
export interface UserDataProviderProps {
/**
* Initial workspaces to be used in the context.
*/
initialWorkspaces?: Workspace[];
/**
* Initial metadata to be used in the context.
*/
initialMetadata?: Record<string, any>;
}
export function UserDataProvider({
children,
initialWorkspaces,
initialMetadata,
}: PropsWithChildren<UserDataProviderProps>) {
const [userContext, setUserContext] = useState({
workspaces: initialWorkspaces || [],
metadata: initialMetadata || {},
});
const value = useMemo(
() => ({ userContext, setUserContext }),
[userContext, setUserContext],
);
return (
// @ts-ignore
<UserDataContext.Provider value={value}>
{children}
</UserDataContext.Provider>
);
}
export const useUserDataContext = () => {
const context = useContext(UserDataContext);
if (context === undefined) {
throw new Error(`useUserDataContext must be used under a UserDataProvider`);
}
return context;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
import { RESOURCE_MEMORY_MULTIPLIER } from '@/utils/CONSTANTS';
import { prettifyNumber } from '@/utils/common/prettifyNumber';
/**
* Prettifies a number of memory.
*
* @param vcpu - The number of memory.
* @returns The prettified number of memory.
*/
export default function prettifyMemory(memory: number) {
return prettifyNumber(memory, {
labels: ['MiB'],
numberOfDecimals: 3,
separator: ' ',
multiplier: RESOURCE_MEMORY_MULTIPLIER,
});
}

View File

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

View File

@@ -0,0 +1,11 @@
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/CONSTANTS';
/**
* Prettifies a number of vCPUs.
*
* @param vcpu - The number of vCPUs.
* @returns The prettified number of vCPUs.
*/
export default function prettifyVCPU(vcpu: number) {
return vcpu / RESOURCE_VCPU_MULTIPLIER;
}

View File

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

View File

@@ -0,0 +1,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
>;

View File

@@ -1,12 +0,0 @@
query getAllAppsWhere($where: apps_bool_exp!) {
apps(where: $where) {
id
name
slug
workspace {
id
name
slug
}
}
}

View File

@@ -1,43 +0,0 @@
fragment GetAppByWorkspaceAndName on apps {
updatedAt
id
slug
subdomain
name
createdAt
isProvisioned
providersUpdated
githubRepository {
id
name
githubAppInstallation {
id
accountLogin
}
}
repositoryProductionBranch
githubRepositoryId
region {
countryCode
city
}
workspace {
name
slug
id
}
workspaceId
config(resolve: true) {
hasura {
adminSecret
}
}
}
query getAppByWorkspaceAndName($workspace: String!, $slug: String!) {
apps(
where: { workspace: { slug: { _eq: $workspace } }, slug: { _eq: $slug } }
) {
...GetAppByWorkspaceAndName
}
}

View File

@@ -1,8 +0,0 @@
query getApps {
apps(order_by: { createdAt: desc }) {
id
slug
name
subdomain
}
}

View File

@@ -1,10 +0,0 @@
query getAppProvisionStatus($workspace: String!, $slug: String!) {
apps(
where: { workspace: { slug: { _eq: $workspace } }, slug: { _eq: $slug } }
) {
id
isProvisioned
subdomain
createdAt
}
}

View File

@@ -2,7 +2,4 @@ query GetWorkspaceAndProject($workspaceSlug: String!, $projectSlug: String) {
workspaces(where: { slug: { _eq: $workspaceSlug } }) {
...Workspace
}
projects: apps(where: { slug: { _eq: $projectSlug } }) {
...Project
}
}

View File

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

View File

@@ -1,5 +0,0 @@
mutation updateApp($id: uuid!, $app: apps_set_input!) {
updateApp(pk_columns: { id: $id }, _set: $app) {
id
}
}

View File

@@ -9,17 +9,6 @@ fragment DeploymentRow on deployments {
commitMessage
}
query getDeployments($id: uuid!, $limit: Int!, $offset: Int!) {
deployments(
where: { appId: { _eq: $id } }
order_by: { deploymentStartedAt: desc }
limit: $limit
offset: $offset
) {
...DeploymentRow
}
}
subscription ScheduledOrPendingDeploymentsSub($appId: uuid!) {
deployments(
where: { deploymentStatus: { _in: ["SCHEDULED"] }, appId: { _eq: $appId } }

View File

@@ -1,5 +0,0 @@
mutation insertFeatureFlag($flag: featureFlags_insert_input!) {
insertFeatureFlag(object: $flag){
id
}
}

View File

@@ -1,5 +0,0 @@
mutation deleteFiles($fileIds: [uuid!]!) {
deleteFiles(where: { id: { _in: $fileIds } }) {
affected_rows
}
}

View File

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

View File

@@ -1,13 +0,0 @@
mutation changePaymentMethod(
$workspaceId: uuid!
$paymentMethod: paymentMethods_insert_input!
) {
# delete all cards on the current workspace
deletePaymentMethods(where: { workspaceId: { _eq: $workspaceId } }) {
affected_rows
}
# add new
insertPaymentMethod(object: $paymentMethod) {
id
}
}

View File

@@ -1,30 +1,8 @@
query getPlans {
plans(order_by: { sort: asc }) {
query GetPlans($where: plans_bool_exp) {
plans(where: $where) {
id
name
isFree
price
isDefault
}
regions {
id
isGdprCompliant
city
country {
name
continent {
name
}
}
}
workspaces {
id
name
slug
paymentMethods {
id
cardBrand
cardLast4
}
}
}

View File

@@ -1,10 +0,0 @@
query getRemoteAppFilesUsage {
filesAggregate {
aggregate {
count
sum {
size
}
}
}
}

View File

@@ -1,54 +0,0 @@
fragment GetRemoteAppUser on users {
id
createdAt
displayName
locale
avatarUrl
email
emailVerified
passwordHash
locale
disabled
phoneNumber
phoneNumberVerified
defaultRole
roles {
role
}
userProviders {
id
provider {
id
}
}
}
fragment GetRemoteAppUserAuthRoles on authRoles {
role
}
query getRemoteAppUser($id: uuid!) {
user(id: $id) {
...GetRemoteAppUser
}
authRoles {
role
}
}
query getRemoteAppUserWhere($where: users_bool_exp!) {
users(where: $where) {
id
displayName
email
defaultRole
}
}
query getRemoteAppById($id: uuid!) {
user(id: $id) {
id
displayName
email
}
}

View File

@@ -1,5 +0,0 @@
mutation confirmProvidersUpdated($id: uuid!) {
updateApp(pk_columns: { id: $id }, _set: { providersUpdated: true }) {
id
}
}

View File

@@ -1,20 +0,0 @@
query getAllUserData {
workspaceMembers {
id
workspace {
id
name
creatorUserId
apps {
id
name
subdomain
config(resolve: true) {
hasura {
adminSecret
}
}
}
}
}
}

View File

@@ -1,6 +0,0 @@
query GetAvatar($userId: uuid!) {
user(id: $userId) {
id
avatarUrl
}
}

View File

@@ -1,22 +0,0 @@
query getOneUser($userId: uuid!) {
user(id: $userId) {
id
displayName
avatarUrl
workspaceMembers {
id
userId
workspaceId
type
workspace {
creatorUserId
id
slug
name
apps {
...Project
}
}
}
}
}

View File

@@ -1,20 +0,0 @@
query getUserAllWorkspaces {
workspaceMembers {
id
userId
workspace {
id
name
slug
apps {
id
name
plan {
id
name
}
slug
}
}
}
}

View File

@@ -1,14 +0,0 @@
query getAppsByWorkspace($workspace_id: uuid!) {
workspace(id: $workspace_id) {
id
name
slug
apps {
name
plan {
id
name
}
}
}
}

View File

@@ -1,5 +0,0 @@
query getWorkspaceInvoices($id: uuid!) {
workspace(id: $id) {
id
}
}

View File

@@ -1,15 +0,0 @@
query getWorkspaceSettings($id: uuid!) {
workspace(id: $id) {
id
name
addressLine1
addressLine2
addressPostalCode
addressPostalCode
addressCity
addressState
addressCountryCode
companyName
email
}
}

View File

@@ -1,49 +0,0 @@
fragment GetWorkspace on workspaces {
id
name
email
companyName
addressLine1
addressLine2
addressPostalCode
addressCity
addressCountryCode
slug
taxIdType
taxIdValue
apps {
id
name
slug
createdAt
workspace {
id
slug
}
}
paymentMethods {
id
cardBrand
cardLast4
stripePaymentMethodId
}
workspaceMembers {
id
user {
id
}
type
}
}
query getWorkspace($id: uuid!) {
workspace(id: $id) {
...GetWorkspace
}
}
query getWorkspaceWhere($where: workspaces_bool_exp!) {
workspaces(where: $where) {
...GetWorkspace
}
}

View File

@@ -1,16 +0,0 @@
query GetWorkspacesAppsById($workspaceId: uuid!) {
workspace(id: $workspaceId) {
id
slug
apps {
id
name
slug
updatedAt
plan {
id
name
}
}
}
}

View File

@@ -1,9 +0,0 @@
query getWorkspaces {
workspaces(order_by: { name: asc }) {
id
createdAt
name
slug
creatorUserId
}
}

View File

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

View File

@@ -0,0 +1,16 @@
import { useGetPlansQuery } from '@/utils/__generated__/graphql';
export default function useProPlan() {
const { data, ...rest } = useGetPlansQuery({
variables: {
where: {
name: {
_eq: 'Pro',
},
},
},
fetchPolicy: 'cache-first',
});
return { data: data?.plans?.at(0), ...rest };
}

View File

@@ -4,7 +4,10 @@ import type {
GetApplicationStateQuery,
GetApplicationStateQueryVariables,
} from '@/utils/__generated__/graphql';
import { useGetApplicationStateQuery } from '@/utils/__generated__/graphql';
import {
GetAllWorkspacesAndProjectsDocument,
useGetApplicationStateQuery,
} from '@/utils/__generated__/graphql';
import type { QueryHookOptions } from '@apollo/client';
import { useEffect } from 'react';
@@ -31,7 +34,7 @@ export default function useProjectRedirectWhenReady(
useEffect(() => {
async function updateOwnCache() {
await client.refetchQueries({
include: ['getOneUser'],
include: [GetAllWorkspacesAndProjectsDocument],
});
}

Some files were not shown because too many files have changed in this diff Show More