Compare commits
389 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90e8843314 | ||
|
|
aa5b360932 | ||
|
|
daa4b8b2ad | ||
|
|
a1c5c97a59 | ||
|
|
b338793d6d | ||
|
|
b1fb4b2400 | ||
|
|
f75e023672 | ||
|
|
8e78c1ff00 | ||
|
|
9cbb0b2986 | ||
|
|
363a3b92e5 | ||
|
|
6a078fc972 | ||
|
|
1091e9674a | ||
|
|
9738108d58 | ||
|
|
65951e1d1d | ||
|
|
b4af994a58 | ||
|
|
c6347e10bc | ||
|
|
278a641bc1 | ||
|
|
3320ddd8c8 | ||
|
|
bc9eff6e41 | ||
|
|
258c608882 | ||
|
|
ae84f269d4 | ||
|
|
0327250b19 | ||
|
|
7f56eabd24 | ||
|
|
be110df83a | ||
|
|
361e648daf | ||
|
|
8a72e20e3d | ||
|
|
125ec390ca | ||
|
|
7cc788a373 | ||
|
|
2a04bc9e5d | ||
|
|
f7c2148ace | ||
|
|
78d35eed09 | ||
|
|
c5ff53c622 | ||
|
|
d21714d169 | ||
|
|
0d16ad41b8 | ||
|
|
82c328eeda | ||
|
|
d991cd8c7e | ||
|
|
e469628ebe | ||
|
|
856bc0a4bb | ||
|
|
9b1fb1ce28 | ||
|
|
a4d16f1835 | ||
|
|
3db8644075 | ||
|
|
7f667f6acb | ||
|
|
685dc6c1e4 | ||
|
|
6f7f2b0a65 | ||
|
|
6d0167b33f | ||
|
|
3ffb60f0ae | ||
|
|
97ced73a3c | ||
|
|
39c86cea25 | ||
|
|
d2d590db7e | ||
|
|
3bdbefc015 | ||
|
|
79081b43c2 | ||
|
|
a4b541f100 | ||
|
|
4523020c33 | ||
|
|
2e2248fd44 | ||
|
|
63358eb80b | ||
|
|
ded674fab6 | ||
|
|
85f2f28902 | ||
|
|
b8e9ad831e | ||
|
|
4e0c5dd1d3 | ||
|
|
b874109c6d | ||
|
|
21b926cc07 | ||
|
|
c35cd47d97 | ||
|
|
8dcd801c7c | ||
|
|
e3199be749 | ||
|
|
284b31e036 | ||
|
|
e7593c7de8 | ||
|
|
e6d862ac1b | ||
|
|
f73672372f | ||
|
|
7f12b98d94 | ||
|
|
d79b66314d | ||
|
|
2a58266592 | ||
|
|
44c2c5467d | ||
|
|
142752cb79 | ||
|
|
b05236a23c | ||
|
|
11a46a0db1 | ||
|
|
cedff501d6 | ||
|
|
7c426dafb2 | ||
|
|
57e7f794f5 | ||
|
|
d4b6cb0acf | ||
|
|
5d0cf8814b | ||
|
|
96cf17bbeb | ||
|
|
ed1a8d458e | ||
|
|
8077495c18 | ||
|
|
b617ec7186 | ||
|
|
bb2da11dd4 | ||
|
|
94fa824e7d | ||
|
|
32d1ee124f | ||
|
|
138bf9eb5a | ||
|
|
d8d9310e0b | ||
|
|
67b2c044b8 | ||
|
|
0b7790ca83 | ||
|
|
55267c680e | ||
|
|
4d856f557f | ||
|
|
64c579cf8c | ||
|
|
eae65c715b | ||
|
|
9e69f9f235 | ||
|
|
8b127fbb62 | ||
|
|
86ba2081ec | ||
|
|
7c2c31082a | ||
|
|
60f705b033 | ||
|
|
ea34635eb2 | ||
|
|
2004687044 | ||
|
|
bd025d43ca | ||
|
|
87a05f7374 | ||
|
|
798f147db7 | ||
|
|
62b7fd2376 | ||
|
|
1ee021b4a3 | ||
|
|
6e61dce297 | ||
|
|
bd744e52dc | ||
|
|
85723d740b | ||
|
|
36e79e7b32 | ||
|
|
f61264b319 | ||
|
|
e84d9d2576 | ||
|
|
ea69d4f0f1 | ||
|
|
212d58bee5 | ||
|
|
c3d6b7beec | ||
|
|
5d5d8ef4f3 | ||
|
|
deb61fe97c | ||
|
|
04d36154b0 | ||
|
|
203cfb10b9 | ||
|
|
9690f871fa | ||
|
|
74a6b93971 | ||
|
|
dd4c0d2430 | ||
|
|
83f2ca5cde | ||
|
|
0c49e757c8 | ||
|
|
e90a9d7696 | ||
|
|
00a06466f5 | ||
|
|
8ca9f76cb2 | ||
|
|
78113dd62a | ||
|
|
adb0ee82c6 | ||
|
|
a41bb6cae6 | ||
|
|
1c59c363ee | ||
|
|
1d99f26fec | ||
|
|
49edb0e627 | ||
|
|
f011e71ae1 | ||
|
|
00c363f808 | ||
|
|
0b2f749ae9 | ||
|
|
cf62a1e6e3 | ||
|
|
8df84d782f | ||
|
|
f0deffafe1 | ||
|
|
a291da661d | ||
|
|
66c3193bc9 | ||
|
|
ac7be49cef | ||
|
|
fa79b77093 | ||
|
|
5823947933 | ||
|
|
333837fb57 | ||
|
|
7fae68f6cf | ||
|
|
f2751f4bac | ||
|
|
089acbbe70 | ||
|
|
6e08a82f49 | ||
|
|
6899ef3b39 | ||
|
|
cad3686364 | ||
|
|
8f2c002715 | ||
|
|
b70d61198f | ||
|
|
d29af2ce6f | ||
|
|
cdc992b888 | ||
|
|
205a20de87 | ||
|
|
b092b8fe08 | ||
|
|
2d40cbf624 | ||
|
|
7b591e8c4c | ||
|
|
72b425a5bc | ||
|
|
971ff92ab4 | ||
|
|
b7f801874d | ||
|
|
ff69f30e47 | ||
|
|
cc1932492d | ||
|
|
f45037e79f | ||
|
|
48658e2925 | ||
|
|
b90bb6b924 | ||
|
|
de61f45bd5 | ||
|
|
fd11e5ca2c | ||
|
|
7839c786ef | ||
|
|
a2bcd6a4b6 | ||
|
|
2cd5b26e0e | ||
|
|
559611af70 | ||
|
|
ffb45f5a49 | ||
|
|
451e80ac12 | ||
|
|
c9f8e523f2 | ||
|
|
331ba03768 | ||
|
|
611b26bc7d | ||
|
|
a446c3efca | ||
|
|
24424ae4dc | ||
|
|
2a5b705c26 | ||
|
|
7f3a32d386 | ||
|
|
11fa442aa8 | ||
|
|
5764f46d99 | ||
|
|
78d501801b | ||
|
|
cc8cc8d45d | ||
|
|
61fc83996b | ||
|
|
9ddb37e9bb | ||
|
|
262828f9a1 | ||
|
|
12f9726ad7 | ||
|
|
845937b552 | ||
|
|
f777a3380a | ||
|
|
5081372cab | ||
|
|
82212345c8 | ||
|
|
32d3f167c5 | ||
|
|
3d5f1ea922 | ||
|
|
97841ee5e8 | ||
|
|
4f3a615ebe | ||
|
|
8e8197691c | ||
|
|
e10389ecf6 | ||
|
|
cbdf6affec | ||
|
|
d19406e694 | ||
|
|
cffc5dc65b | ||
|
|
2b5cb58553 | ||
|
|
7459a9413e | ||
|
|
56871cc9f7 | ||
|
|
8f4d66e52d | ||
|
|
315a820073 | ||
|
|
ca57ad2cbd | ||
|
|
40259344eb | ||
|
|
4749f60a08 | ||
|
|
ac1888514d | ||
|
|
49b4af439b | ||
|
|
61e03d6c70 | ||
|
|
bec0fce497 | ||
|
|
c01568a7dd | ||
|
|
e934216a82 | ||
|
|
701d6b8c84 | ||
|
|
e158e2440a | ||
|
|
fbaa657001 | ||
|
|
559db6d0ec | ||
|
|
4c844930f1 | ||
|
|
3ef503ff81 | ||
|
|
bfcfd236ea | ||
|
|
bfa7033506 | ||
|
|
78c29fcf0e | ||
|
|
f1b934ed22 | ||
|
|
914369c53f | ||
|
|
af379b967e | ||
|
|
c3efb7ec84 | ||
|
|
27cbd48c8c | ||
|
|
236996a903 | ||
|
|
5d0936bb93 | ||
|
|
733c212f2d | ||
|
|
8b47549189 | ||
|
|
3c9c1025ce | ||
|
|
3e46d3873c | ||
|
|
4cf8820d72 | ||
|
|
02a11184fb | ||
|
|
7214d47cc7 | ||
|
|
238b77baad | ||
|
|
81b8e538b4 | ||
|
|
563a37e58d | ||
|
|
bff23720ee | ||
|
|
02cbaeffd2 | ||
|
|
9eb814c79a | ||
|
|
ebc5913bb3 | ||
|
|
4fe4a16964 | ||
|
|
92c475b7a7 | ||
|
|
679b34b031 | ||
|
|
d3186aefbd | ||
|
|
fdecac9d69 | ||
|
|
5077283028 | ||
|
|
f5f662aad1 | ||
|
|
735b779af7 | ||
|
|
4418d6abcf | ||
|
|
049e315c30 | ||
|
|
764597538b | ||
|
|
c8aea785cc | ||
|
|
e0e44b2ff4 | ||
|
|
12280f7c87 | ||
|
|
732a4f40ca | ||
|
|
d67fd599e4 | ||
|
|
a41231927a | ||
|
|
42ec665950 | ||
|
|
7225712a30 | ||
|
|
6593fdd9bb | ||
|
|
40039fece5 | ||
|
|
e5fcfb3cd5 | ||
|
|
218ec314fb | ||
|
|
9367e91d45 | ||
|
|
06c640be2c | ||
|
|
ae45be9816 | ||
|
|
ec4be590d8 | ||
|
|
5c51653aa0 | ||
|
|
7348c15ad1 | ||
|
|
44831e32a7 | ||
|
|
ee0f837762 | ||
|
|
e040979e91 | ||
|
|
68100d63b9 | ||
|
|
9b800046d7 | ||
|
|
807d8574b6 | ||
|
|
77028e4eef | ||
|
|
e0d32aab33 | ||
|
|
75c4c8ae36 | ||
|
|
1d90639e46 | ||
|
|
765b398b21 | ||
|
|
30aae1557c | ||
|
|
a3efc1d131 | ||
|
|
612d754965 | ||
|
|
b2e5f30379 | ||
|
|
3b3e83a218 | ||
|
|
0d5231f1a1 | ||
|
|
1a8332a3ca | ||
|
|
7418105de2 | ||
|
|
425d485f85 | ||
|
|
d8d25b3ea0 | ||
|
|
320513f6f5 | ||
|
|
b37053376d | ||
|
|
c21ba4aebd | ||
|
|
58948c50d4 | ||
|
|
ae324f67fa | ||
|
|
acabf2b168 | ||
|
|
73cb65b9be | ||
|
|
5e7c8395c2 | ||
|
|
c2837209e6 | ||
|
|
638710ea29 | ||
|
|
a79fddbafb | ||
|
|
ab6a8f2add | ||
|
|
69a5661bcf | ||
|
|
0886118f9d | ||
|
|
34fc08ca7c | ||
|
|
153de22713 | ||
|
|
bf4a1f6c2a | ||
|
|
2a67d0f872 | ||
|
|
b156c7b72e | ||
|
|
b484b04ae2 | ||
|
|
2e55c7f46a | ||
|
|
2d983e6ab1 | ||
|
|
df5b4302c3 | ||
|
|
828aed2df9 | ||
|
|
310df10892 | ||
|
|
555fba4400 | ||
|
|
885d10620a | ||
|
|
a8370f5aaa | ||
|
|
bd07905846 | ||
|
|
47a2164549 | ||
|
|
a96c79de00 | ||
|
|
596d0666fc | ||
|
|
9aaa407d29 | ||
|
|
1767b2f105 | ||
|
|
c99c5c4191 | ||
|
|
d845da2503 | ||
|
|
9f1ba1686c | ||
|
|
48b09a58ff | ||
|
|
2169908883 | ||
|
|
ed16c8b5de | ||
|
|
c618503376 | ||
|
|
f306c3940c | ||
|
|
ef125216bb | ||
|
|
fb43fefb5c | ||
|
|
73744c90f0 | ||
|
|
9fbea9787e | ||
|
|
e5f54bc197 | ||
|
|
10a6ae4853 | ||
|
|
d6ca1c7cfd | ||
|
|
bb85a95eda | ||
|
|
e84acf4692 | ||
|
|
2f20a70a28 | ||
|
|
e622ca0d83 | ||
|
|
819e1e97dc | ||
|
|
7c1cca0a43 | ||
|
|
0f51f4e868 | ||
|
|
97a6fcead9 | ||
|
|
b7c799d62c | ||
|
|
18b14b27fd | ||
|
|
67a867c93a | ||
|
|
0a1fb12467 | ||
|
|
78467ee348 | ||
|
|
c24eef0db9 | ||
|
|
2159b8171e | ||
|
|
8903e6abd9 | ||
|
|
7290260990 | ||
|
|
06529a1ea4 | ||
|
|
607d89e2aa | ||
|
|
0cca72311c | ||
|
|
a6525b6467 | ||
|
|
387be37b6e | ||
|
|
c8fd8bbcc7 | ||
|
|
bfb34bad00 | ||
|
|
666a75a233 | ||
|
|
3b050217df | ||
|
|
0ed4481615 | ||
|
|
ac3f12c878 | ||
|
|
65cabb089f | ||
|
|
2905beb0a1 | ||
|
|
83fee54460 | ||
|
|
82898b6dae | ||
|
|
500f76a38d | ||
|
|
5e1e80aa8b | ||
|
|
6d0a126907 | ||
|
|
1b7dcf2121 | ||
|
|
2b9205b6cf | ||
|
|
bdc4d4a88c | ||
|
|
45759c4d4c | ||
|
|
5f9886577a | ||
|
|
fa65496327 | ||
|
|
03777680c1 |
@@ -5,6 +5,5 @@
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@nhost-examples/sveltekit"]
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
|
||||
64
.github/workflows/changesets.yaml
vendored
64
.github/workflows/changesets.yaml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
commit: 'chore: update versions'
|
||||
title: 'chore: update versions'
|
||||
publish: pnpm run release
|
||||
createGithubReleases: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -62,12 +63,39 @@ jobs:
|
||||
uses: ./.github/workflows/dashboard.yaml
|
||||
secrets: inherit
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
- name: Trigger a Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
publish-docker:
|
||||
name: Publish to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
- version
|
||||
- publish-vercel
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
@@ -113,42 +141,6 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: true
|
||||
- name: Create GitHub Release
|
||||
uses: taiki-e/create-gh-release-action@v1
|
||||
with:
|
||||
changelog: dashboard/CHANGELOG.md
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: ${{ env.DASHBOARD_PACKAGE }}@
|
||||
ref: refs/tags/${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||
- name: Remove tag on failure
|
||||
if: failure()
|
||||
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
- name: Trigger a Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
bump-cli:
|
||||
name: Bump Dashboard version in the Nhost CLI
|
||||
|
||||
56
.github/workflows/codeql-analysis.yml
vendored
Normal file
56
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push: {}
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: '20 23 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -2,5 +2,6 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"eslint.workingDirectories": ["./dashboard"]
|
||||
"eslint.workingDirectories": ["./dashboard"],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,171 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@7.0.1
|
||||
- @nhost/nextjs@2.0.1
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- bc9eff6e4: chore: remove support for using backendUrl when instantiating the Nhost client
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bc9eff6e4]
|
||||
- @nhost/nextjs@2.0.0
|
||||
- @nhost/react-apollo@7.0.0
|
||||
|
||||
## 0.21.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 97ced73a3: fix(dashboard): prevent dashboard from resolving secrets
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ed1a8d458: Update alert message on increasing PostgreSQL's volume capacity
|
||||
- 2e2248fd4: feat(dashboard): add SQL editor
|
||||
|
||||
## 0.20.28
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7c2c31082: feat: add support for users to delete their account
|
||||
- @nhost/react-apollo@6.0.1
|
||||
- @nhost/nextjs@1.13.40
|
||||
|
||||
## 0.20.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fa79b7709: chore(dashboard): tweaks and fixes to the service form and dialog
|
||||
- 8df84d782: fix(dashboard): allow resetting custom domains
|
||||
- @nhost/react-apollo@6.0.0
|
||||
- @nhost/nextjs@1.13.39
|
||||
|
||||
## 0.20.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 331ba0376: feat(dashboard): add postgres storage capacity modifier in the settings
|
||||
- b7f801874: feat(dashboard): add new settings page for custom domains
|
||||
|
||||
## 0.20.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.38
|
||||
|
||||
## 0.20.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e10389ecf: fix(dashboard): disable run tab when developing locally
|
||||
- @nhost/react-apollo@5.0.37
|
||||
|
||||
## 0.20.23
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c01568a7d: chore(dashboard): show alert to update oauth providers
|
||||
|
||||
## 0.20.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c3efb7ec8: feat(dashboard): query latest announcement from platform
|
||||
|
||||
## 0.20.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3e46d3873: chore: update link to node18 announcement
|
||||
|
||||
## 0.20.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.36
|
||||
- @nhost/nextjs@1.13.38
|
||||
|
||||
## 0.20.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 75c4c8ae3: feat(dashboard): make env value input multiline
|
||||
|
||||
## 0.20.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 425d485f8: fix(dashboard): make sure dedicated resources pricing follows total resources
|
||||
|
||||
## 0.20.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ae324f67f: fix(dashboard): remove unused graphql fields
|
||||
|
||||
## 0.20.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- df5b4302c: chore(dashboard): remove run feature flag
|
||||
- bf4a1f6c2: feat(dashboard): fetch auth, postgres, hasura and storage versions from dashboard
|
||||
- 34fc08ca7: fix(dashboard/run): show correct private registry in service details
|
||||
- 885d10620: chore(dashboard): change feedback to contact us
|
||||
|
||||
## 0.20.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed16c8b5d: feat(run): add a confirmation dialog when deleting a run service
|
||||
- 216990888: fix(run): center loading indicator when selecting a project
|
||||
|
||||
## 0.20.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9fbea9787: feat: add node18 announcement
|
||||
|
||||
## 0.20.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e84acf469: fix(run): handle subdomain undefined error when creating a new service
|
||||
|
||||
## 0.20.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b7c799d62: feat(run): add dialog to copy registry and URLs
|
||||
|
||||
## 0.20.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8903e6abd: fix(dashboard): show correct egress limit in usage stats
|
||||
|
||||
## 0.20.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 666a75a23: feat(dashboard): add functions execution time and egress volume to usage stats
|
||||
|
||||
## 0.20.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5e1e80aa8: fix(dashboard): show correct locales in user details
|
||||
- @nhost/react-apollo@5.0.35
|
||||
- @nhost/nextjs@1.13.37
|
||||
|
||||
## 0.20.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16-alpine AS pruner
|
||||
FROM node:18-alpine AS pruner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
@@ -7,7 +7,7 @@ RUN yarn global add turbo@1.10.11
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
FROM node:16-alpine AS builder
|
||||
FROM node:18-alpine AS builder
|
||||
ARG TURBO_TOKEN
|
||||
ARG TURBO_TEAM
|
||||
|
||||
@@ -40,7 +40,7 @@ COPY turbo.json turbo.json
|
||||
COPY config/ config/
|
||||
RUN pnpm build:dashboard
|
||||
|
||||
FROM node:16-alpine AS runner
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
|
||||
@@ -30,7 +30,7 @@ test('should show a sidebar with menu items', async () => {
|
||||
const navLocator = page.getByRole('navigation', { name: /main navigation/i });
|
||||
await expect(navLocator).toBeVisible();
|
||||
await expect(navLocator.getByRole('list').getByRole('listitem')).toHaveCount(
|
||||
11,
|
||||
12,
|
||||
);
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /overview/i }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.8",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.10",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/lang-sql": "^6.5.4",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/server": "^11.4.0",
|
||||
@@ -44,6 +44,8 @@
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@tanstack/react-table": "^8.5.30",
|
||||
"@tanstack/react-virtual": "^3.0.0-beta.23",
|
||||
"@uiw/codemirror-theme-github": "^4.21.20",
|
||||
"@uiw/react-codemirror": "^4.21.20",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
@@ -70,6 +72,7 @@
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-resizable-layout": "^0.7.2",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
|
||||
BIN
dashboard/public/illustration-unbox.png
Normal file
BIN
dashboard/public/illustration-unbox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -1,62 +0,0 @@
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { forwardRef, type ForwardedRef } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import AnnouncementContainer, {
|
||||
type AnnouncementContainerProps,
|
||||
} from './AnnouncementContainer';
|
||||
|
||||
export interface AnnouncementProps extends AnnouncementContainerProps {
|
||||
/**
|
||||
* Function called when the announcement is closed.
|
||||
*/
|
||||
onClose?: VoidFunction;
|
||||
/**
|
||||
* The href to use for the announcement link.
|
||||
*/
|
||||
href: string;
|
||||
}
|
||||
|
||||
function Announcement(
|
||||
{ children, slotProps, onClose, href, ...props }: AnnouncementProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
return (
|
||||
<AnnouncementContainer
|
||||
{...props}
|
||||
ref={ref}
|
||||
className="grid grid-flow-col justify-between gap-4"
|
||||
slotProps={{
|
||||
root: {
|
||||
...(slotProps?.root || {}),
|
||||
className: twMerge('w-full py-1.5', slotProps?.root?.className),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span />
|
||||
|
||||
<div className="flex items-center self-center truncate">
|
||||
<a href={href}>
|
||||
<Text className="cursor-pointer truncate hover:underline">
|
||||
{children}
|
||||
</Text>
|
||||
</a>
|
||||
<ArrowRightIcon className="ml-1 h-4 w-4 text-white" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={onClose}
|
||||
aria-label="Close announcement"
|
||||
size="small"
|
||||
className="rounded-sm p-1"
|
||||
>
|
||||
<XIcon className="opacity-65 h-4 w-4" />
|
||||
</Button>
|
||||
</AnnouncementContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Announcement);
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
createElement,
|
||||
forwardRef,
|
||||
type DetailedHTMLProps,
|
||||
type ElementType,
|
||||
type ForwardedRef,
|
||||
type HTMLProps,
|
||||
type PropsWithoutRef,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AnnouncementContainerProps
|
||||
extends PropsWithoutRef<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* Custom component to render as.
|
||||
*/
|
||||
component?: ElementType<any>;
|
||||
/**
|
||||
* Props passed to component slots.
|
||||
*/
|
||||
slotProps?: {
|
||||
/**
|
||||
* Props passed to the root component.
|
||||
*/
|
||||
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||
/**
|
||||
* Props passed to the content component.
|
||||
*/
|
||||
content?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||
};
|
||||
}
|
||||
|
||||
function AnnouncementContainer(
|
||||
{
|
||||
component = 'div',
|
||||
className,
|
||||
children,
|
||||
slotProps,
|
||||
...props
|
||||
}: AnnouncementContainerProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
return createElement(
|
||||
component,
|
||||
{
|
||||
...props,
|
||||
...(slotProps?.root || {}),
|
||||
ref,
|
||||
className: twMerge('w-full overflow-hidden', slotProps?.root?.className),
|
||||
},
|
||||
<div
|
||||
{...(slotProps?.content || {})}
|
||||
className={twMerge(
|
||||
'mx-auto max-w-7xl px-5',
|
||||
className,
|
||||
slotProps?.content?.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(AnnouncementContainer);
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import Announcement from './Announcement';
|
||||
|
||||
interface AnnouncementType {
|
||||
id: string;
|
||||
content: ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface AnnouncementContextProps {
|
||||
/**
|
||||
* The announcement to show.
|
||||
*/
|
||||
announcement?: AnnouncementType;
|
||||
/**
|
||||
* Whether or not to show the announcement.
|
||||
*/
|
||||
showAnnouncement?: boolean;
|
||||
/**
|
||||
* Function to close the announcement.
|
||||
*/
|
||||
handleClose?: () => void;
|
||||
/**
|
||||
* Whether or not the announcement is in view.
|
||||
*/
|
||||
inView?: boolean;
|
||||
}
|
||||
|
||||
// Note: You can define the active announcement here.
|
||||
const announcement: AnnouncementType = {
|
||||
id: 'nhost-run',
|
||||
href: 'https://discord.com/invite/9V7Qb2U',
|
||||
content:
|
||||
'Now you can bring custom and third-party OSS services to run alongside your Nhost projects',
|
||||
};
|
||||
|
||||
export const AnnouncementContext = createContext<AnnouncementContextProps>({});
|
||||
|
||||
export default function AnnouncementProvider({ children }: PropsWithChildren) {
|
||||
const { ref, inView } = useInView();
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!announcement ||
|
||||
window.localStorage.getItem(announcement.id) === '1'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAnnouncement(true);
|
||||
}, []);
|
||||
|
||||
function handleClose() {
|
||||
setShowAnnouncement(false);
|
||||
window.localStorage.setItem(announcement?.id, '1');
|
||||
}
|
||||
|
||||
const announcementValue = useMemo(
|
||||
() => ({ showAnnouncement, announcement, handleClose, inView }),
|
||||
[inView, showAnnouncement],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnnouncementContext.Provider value={announcementValue}>
|
||||
{announcement && showAnnouncement && (
|
||||
<>
|
||||
<Announcement
|
||||
ref={ref}
|
||||
href={announcement.href}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{announcement.content}
|
||||
</Announcement>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</AnnouncementContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './Announcement';
|
||||
export * from './AnnouncementProvider';
|
||||
export { default as useAnnouncement } from './useAnnouncement';
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import { AnnouncementContext } from './AnnouncementProvider';
|
||||
|
||||
export default function useAnnouncement() {
|
||||
const context = useContext(AnnouncementContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useAnnouncement must be used within an AnnouncementProvider',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
70
dashboard/src/components/common/ContactUs/ContactUs.tsx
Normal file
70
dashboard/src/components/common/ContactUs/ContactUs.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ContactUsProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
export default function FeedbackForm({ className, ...props }: ContactUsProps) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row gap-2 py-4 px-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text variant="h3" component="h2">
|
||||
Contact us
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
To report issues with Nhost, please open a GitHub issue in the{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
nhost/nhost
|
||||
</Link>{' '}
|
||||
repository.
|
||||
</Text>
|
||||
<Text>
|
||||
For issues related to the CLI, please visit the{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/cli/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
nhost/cli
|
||||
</Link>{' '}
|
||||
repository.
|
||||
</Text>
|
||||
<Text>
|
||||
If you need assistance or have any questions, feel free to join us on{' '}
|
||||
<Link
|
||||
href="https://discord.com/invite/9V7Qb2U"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
Discord
|
||||
</Link>
|
||||
. Alternatively, if you prefer, you can also open a{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/discussions/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
GitHub discussion
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text>We're here to help, so don't hesitate to reach out!</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/common/ContactUs/index.ts
Normal file
2
dashboard/src/components/common/ContactUs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ContactUs';
|
||||
export { default as ContactUs } from './ContactUs';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
|
||||
export default function DepricationNotice() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
return (
|
||||
!currentProject?.providersUpdated && (
|
||||
<Alert severity="warning" className="grid place-content-center">
|
||||
<Text color="warning" className="max-w-3xl text-sm">
|
||||
On December 1st the old backend domain will cease to work. You need to
|
||||
make sure your client is instantiated using the subdomain and region
|
||||
and update your oauth2 settings. You can find more information{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://github.com/nhost/nhost/discussions/2303"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ContactUs } from './DepricationNotice';
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useInsertFeedbackOneMutation } from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface FeedbackFormProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
// TODO: Use `react-hook-form` here instead of the custom form implementation
|
||||
export default function FeedbackForm({
|
||||
className,
|
||||
...props
|
||||
}: FeedbackFormProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertFeedback, { loading }] = useInsertFeedbackOneMutation();
|
||||
const user = useUserData();
|
||||
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [feedbackSent, setFeedbackSent] = useState(false);
|
||||
|
||||
function handleClose() {
|
||||
setTimeout(() => {
|
||||
setFeedbackSent(false);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
const feedbackWithProjectInfo = [
|
||||
currentProject && `Project ID: ${currentProject.id}`,
|
||||
typeof window !== 'undefined' && `URL: ${window.location.href}`,
|
||||
feedback,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
try {
|
||||
await insertFeedback({
|
||||
variables: {
|
||||
feedback: {
|
||||
feedback: feedbackWithProjectInfo,
|
||||
},
|
||||
},
|
||||
});
|
||||
setFeedbackSent(true);
|
||||
setFeedback('');
|
||||
} catch (error) {
|
||||
// TODO: Display error to user and use a logging solution
|
||||
}
|
||||
}
|
||||
|
||||
if (feedbackSent) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row justify-center gap-4 py-4 px-5 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src="/assets/FeedbackReceived.svg"
|
||||
alt="Light bulb with a checkmark"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Text variant="h3" component="h2" className="text-center">
|
||||
Feedback Received
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Thanks for sending us your thoughts! Feel free to send more feedback
|
||||
as you explore the beta, and stay tuned for updates.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="mt-2 text-sm+ font-normal"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row gap-2 py-4 px-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text variant="h3" component="h2">
|
||||
Leave Feedback
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Nhost is still in beta and not everything is in place yet, but we'd
|
||||
love to know what you think of it so far.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid grid-flow-row gap-2">
|
||||
<div className="grid grid-flow-col place-content-between gap-2">
|
||||
<Text className="font-medium">
|
||||
What do you think we should improve?
|
||||
</Text>
|
||||
|
||||
<Avatar
|
||||
className="h-6 w-6 rounded-full"
|
||||
alt={user?.displayName}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
{user?.displayName}
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
multiline
|
||||
value={feedback}
|
||||
onChange={(event) => setFeedback(event.target.value)}
|
||||
placeholder="Your feedback"
|
||||
rows={6}
|
||||
required
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={!feedback} loading={loading}>
|
||||
Send Feedback
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './FeedbackForm';
|
||||
export { default as FeedbackForm } from './FeedbackForm';
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ChangePlanModal } from '@/features/projects/common/components/ChangePlanModal';
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
interface UpgradeToProBannerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function UpgradeToProBanner({
|
||||
title,
|
||||
description,
|
||||
}: UpgradeToProBannerProps) {
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
className="flex flex-col p-4 space-y-4 rounded-md lg:flex-row lg:items-center lg:space-y-0"
|
||||
>
|
||||
<div className="flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col space-y-2 xs:flex-row xs:space-y-0 xs:space-x-2">
|
||||
<Text>Available with</Text>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<NhostIcon />
|
||||
<Text sx={{ color: 'primary.main' }} className="font-semibold">
|
||||
Nhost Pro
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Text variant="h3">{title}</Text>
|
||||
<Text>{description}</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-2">
|
||||
<Button
|
||||
className="rounded-md"
|
||||
onClick={() => {
|
||||
if (isOwner) {
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
openAlertDialog({
|
||||
title: "You can't upgrade this project",
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Ask an owner of this workspace to upgrade the project.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
secondaryButtonText: 'I understand',
|
||||
hidePrimaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
<Link
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium text-center"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
See all features
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs mx-auto">
|
||||
<Image
|
||||
src="/illustration-unbox.png"
|
||||
width={400}
|
||||
height={260}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UpgradeToProBanner } from './UpgradeToProBanner';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
|
||||
@@ -75,14 +75,14 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
hideChevron
|
||||
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
>
|
||||
Feedback
|
||||
Contact us
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<FeedbackForm className="max-w-md" />
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
|
||||
import { Nav } from '@/components/presentational/Nav';
|
||||
@@ -171,7 +171,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
className="w-full"
|
||||
role={undefined}
|
||||
>
|
||||
<ListItem.Text>Feedback</ListItem.Text>
|
||||
<ListItem.Text>Contact us</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
</Dropdown.Trigger>
|
||||
@@ -180,7 +180,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm className="max-w-md" />
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -128,7 +128,11 @@ export default function SettingsContainer({
|
||||
icon}
|
||||
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text className="text-lg font-semibold">{title}</Text>
|
||||
{typeof title === 'string' ? (
|
||||
<Text className="text-lg font-semibold">{title}</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
|
||||
{description && <Text color="secondary">{description}</Text>}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
@@ -45,44 +46,47 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex flex-col flex-auto w-full overflow-scroll overflow-x-hidden"
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row gap-2 place-content-center"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository and
|
||||
you only want these changes to apply to a subset of them, please
|
||||
check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<DepricationNotice />
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository
|
||||
and you only want these changes to apply to a subset of them,
|
||||
please check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
|
||||
@@ -200,6 +200,14 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Secrets
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/custom-domains"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Custom Domains
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ForwardedRef, SVGProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function NhostIcon(
|
||||
props: SVGProps<SVGSVGElement>,
|
||||
ref: ForwardedRef<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Logo of Nhost"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_9802_20458)">
|
||||
<rect width="24" height="24" fill="#0052CD" />
|
||||
<path
|
||||
d="M17.4656 7.39804L12.4705 4.51369C12.0223 4.25553 11.466 4.25553 11.0169 4.51369C10.5688 4.77276 10.2906 5.25455 10.2906 5.77179V6.14813L9.96517 5.95996C9.51702 5.70179 8.96069 5.70179 8.51163 5.95996C8.06348 6.21903 7.78531 6.70082 7.78531 7.21896V7.5953L7.45988 7.40713C7.01173 7.14897 6.4554 7.14897 6.00634 7.40713C5.55819 7.66621 5.28003 8.14799 5.28003 8.66614V17.7037C5.28003 17.9637 5.43093 18.2055 5.66546 18.3182C5.89908 18.4318 6.1827 18.4009 6.38632 18.24L8.86342 16.2865L12.6832 18.4918C12.7886 18.5527 12.9068 18.5827 13.025 18.5827C13.1431 18.5827 13.2613 18.5518 13.3668 18.4918C13.5777 18.37 13.7086 18.1437 13.7086 17.9001V12.4613C13.7086 11.5687 13.2286 10.7378 12.4559 10.2915L11.2033 9.56789V5.7727C11.2033 5.57998 11.3069 5.4 11.4742 5.30364C11.6414 5.20728 11.8487 5.20728 12.0159 5.30364L17.0111 8.18708C17.5028 8.4707 17.8083 9.00066 17.8083 9.56789V16.3402C17.8083 16.5329 17.7046 16.7129 17.5374 16.8092L16.2138 17.5737V11.0142C16.2138 10.1215 15.7339 9.29064 14.9612 8.84431L11.8859 7.06897V8.12072L14.5058 9.63334C14.9976 9.91696 15.303 10.446 15.303 11.0142V17.9673C15.303 18.21 15.4339 18.4373 15.6448 18.5591C15.7502 18.62 15.8684 18.65 15.9866 18.65C16.1048 18.65 16.2229 18.6191 16.3284 18.5591L17.9937 17.5974C18.4419 17.3383 18.72 16.8565 18.72 16.3383V9.56608C18.7182 8.67614 18.2382 7.84438 17.4656 7.39804ZM11.9987 11.0805C12.4905 11.3641 12.7959 11.8932 12.7959 12.4613V17.5064L9.63246 15.6802L10.6478 14.8803C10.9996 14.603 11.2014 14.1876 11.2014 13.7394V10.6215L11.9987 11.0805ZM10.2906 10.0942V13.7376C10.2906 13.9049 10.2152 14.0603 10.0842 14.163L6.19088 17.2328V8.66523C6.19088 8.47251 6.29451 8.29253 6.46177 8.19617C6.62903 8.09981 6.83629 8.09981 7.00355 8.19617L7.78531 8.64705V15.1057L8.69616 14.3876V7.21896C8.69616 7.02625 8.79979 6.84626 8.96705 6.7499C9.13431 6.65355 9.34157 6.65355 9.50883 6.7499L10.2906 7.20078V9.04157L9.37975 8.51524V9.56789L10.2906 10.0942Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9802_20458">
|
||||
<rect width="24" height="24" rx="4" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(NhostIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as NhostIcon } from './NhostIcon';
|
||||
100
dashboard/src/components/settings/ProvidersUpdatedAlert.tsx
Normal file
100
dashboard/src/components/settings/ProvidersUpdatedAlert.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function ProvidersUpdatedAlert() {
|
||||
const theme = useTheme();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [confirmed, setConfirmed] = useState(true);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
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: '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't.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
onPrimaryAction: handleSubmitConfirmation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid items-center grid-flow-row gap-2 p-4 place-items-center 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'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="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className={
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text-white hover:bg-brown'
|
||||
: 'text-black hover:bg-orange-300'
|
||||
}
|
||||
onClick={handleOpenConfirmationDialog}
|
||||
>
|
||||
I have updated all Redirect URLs
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/components/settings/index.ts
Normal file
1
dashboard/src/components/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProvidersUpdatedAlert } from './ProvidersUpdatedAlert';
|
||||
@@ -7,14 +7,15 @@ import MaterialLinearProgress, {
|
||||
|
||||
export interface LinearProgressProps extends MaterialLinearProgressProps {}
|
||||
|
||||
const LinearProgress = styled(MaterialLinearProgress)(({ theme }) => ({
|
||||
const LinearProgress = styled(MaterialLinearProgress)(({ theme, value }) => ({
|
||||
height: 12,
|
||||
borderRadius: 1,
|
||||
[`&.${linearProgressClasses.colorPrimary}`]: {
|
||||
backgroundColor: theme.palette.grey[300],
|
||||
},
|
||||
[`& .${linearProgressClasses.bar}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
backgroundColor:
|
||||
value >= 100 ? theme.palette.error.dark : theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
|
||||
function ArrowsClockwise(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-label="Update"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M11.0103 6.23227H14.0103V3.23227"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.11084 4.11091C4.62156 3.60019 5.22788 3.19506 5.89517 2.91866C6.56246 2.64226 7.27766 2.5 7.99993 2.5C8.7222 2.5 9.4374 2.64226 10.1047 2.91866C10.772 3.19506 11.3783 3.60019 11.889 4.11091L14.0103 6.23223"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.98975 9.76773H1.98975V12.7677"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.8892 11.8891C11.3785 12.3998 10.7722 12.8049 10.1049 13.0813C9.43762 13.3577 8.72242 13.5 8.00015 13.5C7.27788 13.5 6.56269 13.3577 5.89539 13.0813C5.2281 12.8049 4.62179 12.3998 4.11107 11.8891L1.98975 9.76776"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
ArrowsClockwise.displayName = 'NhostArrowsClockwise';
|
||||
|
||||
export default ArrowsClockwise;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ArrowsClockwise } from './ArrowsClockwise';
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function TerminalIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Trash"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.49851 3.43968L2.93795 2.94141L1.94141 4.06252L2.50196 4.56079L6.37134 8.00024L2.50196 11.4397L1.94141 11.938L2.93795 13.0591L3.49851 12.5608L7.99851 8.56079C8.15863 8.41847 8.25024 8.21446 8.25024 8.00024C8.25024 7.78601 8.15863 7.582 7.99851 7.43968L3.49851 3.43968ZM7.99987 11.2502H7.24987V12.7502H7.99987H13.9999H14.7499V11.2502H13.9999H7.99987Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
TerminalIcon.displayName = 'NhostTerminalIcon';
|
||||
|
||||
export default TerminalIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TerminalIcon } from './TerminalIcon';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useDeleteUserAccountMutation,
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { type ApolloError } from '@apollo/client';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function ConfirmDeleteAccountModal({
|
||||
close,
|
||||
onDelete,
|
||||
}: {
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const user = useUserData();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const userHasProjects =
|
||||
!loading && data?.workspaces.some((workspace) => workspace.projects.length);
|
||||
|
||||
const userData = useUserData();
|
||||
|
||||
const [deleteUserAccount] = useDeleteUserAccountMutation({
|
||||
variables: { id: userData?.id },
|
||||
});
|
||||
|
||||
const onClickConfirm = async () => {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteUserAccount(),
|
||||
{
|
||||
loading: 'Deleting your account...',
|
||||
success: `The account has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting your account. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Account?
|
||||
</Text>
|
||||
|
||||
{userHasProjects && (
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
You still have active projects. Please delete your projects before
|
||||
proceeding with the account deletion.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete my account`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Project #1"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={onClickConfirm}
|
||||
disabled={userHasProjects}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useSignOut();
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const onDelete = async () => {
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
};
|
||||
|
||||
const confirmDeleteAccount = async () => {
|
||||
openDialog({
|
||||
component: (
|
||||
<ConfirmDeleteAccountModal close={closeDialog} onDelete={onDelete} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Delete Account"
|
||||
description="Please proceed with caution as the removal of your Personal Account and its contents from the Nhost platform is irreversible. This action will permanently delete your account and all associated data."
|
||||
className="px-0"
|
||||
slotProps={{
|
||||
submitButton: { className: 'hidden' },
|
||||
footer: { className: 'hidden' },
|
||||
}}
|
||||
>
|
||||
<Box className="grid grid-flow-row border-t-1">
|
||||
<Button
|
||||
color="error"
|
||||
className="mx-4 mt-4 justify-self-end"
|
||||
onClick={confirmDeleteAccount}
|
||||
>
|
||||
Delete Personal Account
|
||||
</Button>
|
||||
</Box>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteAccount } from './DeleteAccount';
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteUserAccount($id: uuid!) {
|
||||
deleteUser(id: $id) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -28,15 +30,6 @@ export type AuthServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_AUTH_VERSIONS = [
|
||||
'0.21.2',
|
||||
'0.20.1',
|
||||
'0.20.0',
|
||||
'0.19.3',
|
||||
'0.19.2',
|
||||
'0.19.1',
|
||||
];
|
||||
|
||||
export default function AuthServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -49,9 +42,16 @@ export default function AuthServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: authVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Auth,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.auth || {};
|
||||
const versions = authVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_AUTH_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetAuthenticationSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
auth {
|
||||
@@ -24,6 +24,13 @@ query GetAuthenticationSettings($appId: uuid!) {
|
||||
expiresIn
|
||||
}
|
||||
}
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
user {
|
||||
email {
|
||||
allowed
|
||||
@@ -38,6 +45,10 @@ query GetAuthenticationSettings($appId: uuid!) {
|
||||
default
|
||||
rating
|
||||
}
|
||||
locale {
|
||||
allowed
|
||||
default
|
||||
}
|
||||
}
|
||||
version
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { copy } from '@/utils/copy';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
@@ -146,6 +147,14 @@ export default function EditUserForm({
|
||||
dataRoles?.config?.auth?.user?.roles?.allowed,
|
||||
);
|
||||
|
||||
const { data } = useGetProjectLocalesQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
||||
|
||||
/**
|
||||
* This will change the `disabled` field in the user to its opposite.
|
||||
* If the user is disabled, it will be enabled and vice versa.
|
||||
@@ -374,12 +383,11 @@ export default function EditUserForm({
|
||||
error={!!errors.locale}
|
||||
helperText={errors?.locale?.message}
|
||||
>
|
||||
<Option key="en" value="en">
|
||||
en
|
||||
</Option>
|
||||
<Option key="fr" value="fr">
|
||||
fr
|
||||
</Option>
|
||||
{allowedLocales.map((locale) => (
|
||||
<Option key={locale} value={locale}>
|
||||
{locale}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledSelect>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon'
|
||||
import { LockIcon } from '@/components/ui/v2/icons/LockIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TerminalIcon } from '@/components/ui/v2/icons/TerminalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UsersIcon } from '@/components/ui/v2/icons/UsersIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
@@ -86,7 +87,9 @@ function DataBrowserSidebarContent({
|
||||
const isGitHubConnected = !!currentProject?.githubRepository;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
asPath,
|
||||
query: { workspaceSlug, appSlug, dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = router;
|
||||
|
||||
@@ -108,6 +111,8 @@ function DataBrowserSidebarContent({
|
||||
*/
|
||||
const [sidebarMenuTable, setSidebarMenuTable] = useState<string>();
|
||||
|
||||
const sqlEditorHref = `/${workspaceSlug}/${appSlug}/database/browser/default/editor`;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSchema) {
|
||||
return;
|
||||
@@ -258,194 +263,135 @@ function DataBrowserSidebarContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
{schemas && schemas.length > 0 && (
|
||||
<Select
|
||||
renderValue={(option) => (
|
||||
<span className="grid grid-flow-col items-center gap-1">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
slotProps={{
|
||||
listbox: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
popper: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
}}
|
||||
value={selectedSchema}
|
||||
onChange={(_event, value) => setSelectedSchema(value as string)}
|
||||
>
|
||||
{schemas.map((schema) => (
|
||||
<Option
|
||||
className="grid grid-flow-col items-center gap-1"
|
||||
value={schema.schema_name}
|
||||
key={schema.schema_name}
|
||||
>
|
||||
<Text className="text-sm">
|
||||
<Text component="span" color="disabled">
|
||||
schema.
|
||||
</Text>
|
||||
<Text component="span" className="font-medium">
|
||||
{schema.schema_name}
|
||||
</Text>
|
||||
</Text>
|
||||
{(isSchemaLocked(schema.schema_name) || isGitHubConnected) && (
|
||||
<LockIcon
|
||||
className="h-3 w-3"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{isGitHubConnected && (
|
||||
<Box
|
||||
className="mt-1.5 grid grid-flow-row justify-items-start gap-2 rounded-md p-2"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
<Box className="flex h-full flex-col justify-between">
|
||||
<Box className="flex flex-col px-2">
|
||||
{schemas && schemas.length > 0 && (
|
||||
<Select
|
||||
renderValue={(option) => (
|
||||
<span className="grid grid-flow-col items-center gap-1">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
slotProps={{
|
||||
listbox: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
popper: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
}}
|
||||
value={selectedSchema}
|
||||
onChange={(_event, value) => setSelectedSchema(value as string)}
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isSelectedSchemaLocked && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
|
||||
onSidebarItemClick();
|
||||
}}
|
||||
disabled={isGitHubConnected}
|
||||
>
|
||||
New Table
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{schemas && schemas.length > 0 && tablesInSelectedSchema.length === 0 && (
|
||||
<Text className="py-1.5 px-2 text-xs" color="disabled">
|
||||
No tables found.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<nav aria-label="Database navigation">
|
||||
{tablesInSelectedSchema.length > 0 && (
|
||||
<List className="grid gap-1 pb-6">
|
||||
{tablesInSelectedSchema.map((table) => {
|
||||
const tablePath = `${table.table_schema}.${table.table_name}`;
|
||||
const isSelected = `${schemaSlug}.${tableSlug}` === tablePath;
|
||||
const isSidebarMenuOpen = sidebarMenuTable === tablePath;
|
||||
|
||||
return (
|
||||
<ListItem.Root
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
{schemas.map((schema) => (
|
||||
<Option
|
||||
className="grid grid-flow-col items-center gap-1"
|
||||
value={schema.schema_name}
|
||||
key={schema.schema_name}
|
||||
>
|
||||
<Text className="text-sm">
|
||||
<Text component="span" color="disabled">
|
||||
schema.
|
||||
</Text>
|
||||
<Text component="span" className="font-medium">
|
||||
{schema.schema_name}
|
||||
</Text>
|
||||
</Text>
|
||||
{(isSchemaLocked(schema.schema_name) || isGitHubConnected) && (
|
||||
<LockIcon
|
||||
className="h-3 w-3"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{isGitHubConnected && (
|
||||
<Box
|
||||
className="mt-1.5 grid grid-flow-row justify-items-start gap-2 rounded-md p-2"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
{!isSelectedSchemaLocked && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
onSidebarItemClick();
|
||||
}}
|
||||
disabled={isGitHubConnected}
|
||||
>
|
||||
New Table
|
||||
</Button>
|
||||
)}
|
||||
{schemas && schemas.length > 0 && tablesInSelectedSchema.length === 0 && (
|
||||
<Text className="py-1.5 px-2 text-xs" color="disabled">
|
||||
No tables found.
|
||||
</Text>
|
||||
)}
|
||||
<nav aria-label="Database navigation">
|
||||
{tablesInSelectedSchema.length > 0 && (
|
||||
<List className="grid gap-1 pb-6">
|
||||
{tablesInSelectedSchema.map((table) => {
|
||||
const tablePath = `${table.table_schema}.${table.table_name}`;
|
||||
const isSelected = `${schemaSlug}.${tableSlug}` === tablePath;
|
||||
const isSidebarMenuOpen = sidebarMenuTable === tablePath;
|
||||
return (
|
||||
<ListItem.Root
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content menu PaperProps={{ className: 'w-52' }}>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52' }}
|
||||
>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -453,68 +399,135 @@ function DataBrowserSidebarContent({
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
handleDeleteTableClick(
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'error.main' }}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={isSelected}
|
||||
disabled={tablePath === removableTable}
|
||||
className="group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
sx={{
|
||||
paddingRight:
|
||||
(isSelected || isSidebarMenuOpen) &&
|
||||
'2.25rem !important',
|
||||
}}
|
||||
component={NavLink}
|
||||
href={`/${workspaceSlug}/${appSlug}/database/browser/default/${table.table_schema}/${table.table_name}`}
|
||||
onClick={() => {
|
||||
if (onSidebarItemClick) {
|
||||
onSidebarItemClick(`default.${tablePath}`);
|
||||
}
|
||||
}}
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
handleDeleteTableClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'error.main' }}
|
||||
/>
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text>{table.table_name}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={isSelected}
|
||||
disabled={tablePath === removableTable}
|
||||
className="group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
sx={{
|
||||
paddingRight:
|
||||
(isSelected || isSidebarMenuOpen) &&
|
||||
'2.25rem !important',
|
||||
}}
|
||||
component={NavLink}
|
||||
href={`/${workspaceSlug}/${appSlug}/database/browser/default/${table.table_schema}/${table.table_name}`}
|
||||
onClick={() => {
|
||||
if (onSidebarItemClick) {
|
||||
onSidebarItemClick(`default.${tablePath}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItem.Text>{table.table_name}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
<Box className="border-t">
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={asPath === sqlEditorHref}
|
||||
className="flex border group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
component={NavLink}
|
||||
href={sqlEditorHref}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-center space-x-4">
|
||||
<TerminalIcon />
|
||||
<span className="flex">SQL Editor</span>
|
||||
</div>
|
||||
</ListItem.Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -580,7 +593,7 @@ export default function DataBrowserSidebar({
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pt-2 pb-17 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:py-2.5 sm:transition-none',
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 pt-2 pb-17 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:pt-2.5 sm:pb-0 sm:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlayIcon } from '@/components/ui/v2/icons/PlayIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Table } from '@/components/ui/v2/Table';
|
||||
import { TableBody } from '@/components/ui/v2/TableBody';
|
||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||
import { TableHead } from '@/components/ui/v2/TableHead';
|
||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useRunSQL } from '@/features/database/dataGrid/hooks/useRunSQL';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { PostgreSQL, sql } from '@codemirror/lang-sql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useResizable } from 'react-resizable-layout';
|
||||
|
||||
export default function SQLEditor() {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [sqlCode, setSQLCode] = useState('');
|
||||
const [track, setTrack] = useState(false);
|
||||
const [cascade, setCascade] = useState(false);
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [isMigration, setIsMigration] = useState(false);
|
||||
const [migrationName, setMigrationName] = useState('');
|
||||
|
||||
const onChange = useCallback((value: string) => setSQLCode(value), []);
|
||||
|
||||
const { runSQL, loading, errorMessage, commandOk, rows, columns } = useRunSQL(
|
||||
sqlCode,
|
||||
track,
|
||||
cascade,
|
||||
readOnly,
|
||||
isMigration,
|
||||
migrationName,
|
||||
);
|
||||
|
||||
const { position, separatorProps } = useResizable({
|
||||
axis: 'y',
|
||||
initial: 400,
|
||||
min: 50,
|
||||
reverse: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="flex flex-1 flex-col justify-center overflow-hidden">
|
||||
<Box className="flex flex-col space-y-2 border-b p-4">
|
||||
<Text className="font-semibold">Raw SQL</Text>
|
||||
<Box className="flex flex-col justify-between space-y-2 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<Box className="flex w-full flex-col space-y-2 lg:flex-row lg:space-x-4 lg:space-y-0 xl:h-10">
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Track this
|
||||
</Text>
|
||||
}
|
||||
checked={track}
|
||||
onChange={(event) => setTrack(event.currentTarget.checked)}
|
||||
/>
|
||||
<Tooltip title="If you are creating tables, views or functions, checking this will also expose them over the GraphQL API as top level fields. Functions only intended to be used as computed fields should not be tracked.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Cascade metadata
|
||||
</Text>
|
||||
}
|
||||
checked={cascade}
|
||||
onChange={(e) => setCascade(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Tooltip title="Cascade actions on all dependent metadata references, like relationships and permissions">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Read only
|
||||
</Text>
|
||||
}
|
||||
checked={readOnly}
|
||||
onChange={(e) => setReadOnly(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Tooltip title="When set to true, the request will be run in READ ONLY transaction access mode which means only select queries will be successful. This flag ensures that the GraphQL schema is not modified and is hence highly performant.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{!isPlatform && (
|
||||
<Box className="flex flex-col space-x-0 space-y-2 xl:flex-row xl:space-x-4 xl:space-y-0">
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
This is a migration
|
||||
</Text>
|
||||
}
|
||||
checked={isMigration}
|
||||
onChange={(e) => setIsMigration(e.target.checked)}
|
||||
/>
|
||||
<Tooltip title="Create a migration file with the SQL statement">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{isMigration && (
|
||||
<Input
|
||||
name="isMigration"
|
||||
id="isMigration"
|
||||
placeholder="migration_name"
|
||||
className="h-auto w-auto max-w-md"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
onChange={(e) => setMigrationName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
disabled={loading || !sqlCode.trim()}
|
||||
variant="contained"
|
||||
className="self-start"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runSQL}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<CodeMirror
|
||||
value={sqlCode}
|
||||
height="100%"
|
||||
className="min-h-[100px] flex-1 overflow-y-auto"
|
||||
theme={theme.palette.mode === 'light' ? githubLight : githubDark}
|
||||
extensions={[sql({ dialect: PostgreSQL })]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="h-2 border-t hover:cursor-row-resize"
|
||||
sx={{
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
{...separatorProps}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="flex items-start overflow-auto p-4"
|
||||
style={{ height: position }}
|
||||
>
|
||||
{loading && (
|
||||
<ActivityIndicator
|
||||
className="mx-auto self-center"
|
||||
circularProgressProps={{
|
||||
className: 'w-5 h-5',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="mx-auto grid grid-flow-row place-content-center gap-2 self-center"
|
||||
>
|
||||
<code>{errorMessage}</code>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && commandOk && (
|
||||
<Alert
|
||||
severity="success"
|
||||
className="mx-auto grid grid-flow-row place-content-center gap-2 self-center"
|
||||
>
|
||||
<code>Success, no rows returned</code>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && (
|
||||
<Table
|
||||
style={{
|
||||
tableLayout: 'auto',
|
||||
}}
|
||||
className="w-auto"
|
||||
>
|
||||
<TableHead
|
||||
sx={{
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<TableRow>
|
||||
{columns.map((header) => (
|
||||
<TableCell
|
||||
key={header}
|
||||
scope="col"
|
||||
className="whitespace-nowrap border px-6 py-3 font-bold"
|
||||
>
|
||||
{header}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<TableRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={String(rowIndex)}
|
||||
// className="px-6 py-4 border whitespace-nowrap"
|
||||
>
|
||||
{row.map((value, valueIndex) => (
|
||||
<TableCell
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${value}-${valueIndex}`}
|
||||
className="whitespace-nowrap border px-6 py-4"
|
||||
>
|
||||
{value}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SQLEditor } from './SQLEditor';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useRunSQL } from './useRunSQL';
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { parseIdentifiersFromSQL } from '@/utils/sql';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function useRunSQL(
|
||||
sqlCode: string,
|
||||
track: boolean,
|
||||
cascade: boolean,
|
||||
readOnly: boolean,
|
||||
isMigration: boolean,
|
||||
migrationName: string,
|
||||
) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [commandOk, setCommandOk] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<string[][]>([[]]);
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
);
|
||||
|
||||
const adminSecret =
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret;
|
||||
|
||||
const toastStyle = getToastStyleProps();
|
||||
|
||||
const createMigration = async (
|
||||
inputSQL: string,
|
||||
migration: string,
|
||||
isCascade: boolean,
|
||||
) => {
|
||||
try {
|
||||
const migrationApiResponse = await fetch(`${appUrl}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify({
|
||||
name: migration,
|
||||
datasource: 'default',
|
||||
up: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: inputSQL,
|
||||
cascade: isCascade,
|
||||
read_only: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: '-- Could not auto-generate a down migration.',
|
||||
cascade: isCascade,
|
||||
read_only: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!migrationApiResponse.ok) {
|
||||
throw new Error('Migration API call failed');
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
};
|
||||
} catch (createMigrationError) {
|
||||
toast.error('An error happened when calling the migration API', {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
|
||||
return {
|
||||
error: createMigrationError,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const sendSQLToHasura = async (
|
||||
inputSQL: string,
|
||||
isCascade: boolean,
|
||||
isReadOnly: boolean,
|
||||
) => {
|
||||
try {
|
||||
if (!inputSQL) {
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
queryApiError: 'No SQL provided',
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${appUrl}/v2/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify({
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: inputSQL,
|
||||
cascade: isCascade,
|
||||
read_only: isReadOnly,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json();
|
||||
const queryApiError =
|
||||
errorResponse?.internal?.error?.message || 'Unknown error';
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: queryApiError,
|
||||
};
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (responseBody?.result_type === 'TuplesOk') {
|
||||
return {
|
||||
result_type: 'TuplesOk',
|
||||
columns: responseBody.result[0],
|
||||
rows: responseBody.result.slice(1),
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (responseBody?.result_type === 'CommandOk') {
|
||||
return {
|
||||
result_type: 'CommandOk',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
// If the result_type is neither TuplesOk nor CommandOk
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: 'Unknown response type',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: error.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateMetadata = async (inputSQL: string) => {
|
||||
const entities = parseIdentifiersFromSQL(inputSQL);
|
||||
|
||||
const tablesOrViewEntities = entities.filter(
|
||||
(entity) => entity.type !== 'function',
|
||||
);
|
||||
const functionEntities = entities.filter(
|
||||
(entity) => entity.type === 'function',
|
||||
);
|
||||
|
||||
const trackTablesOrViews = tablesOrViewEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_table',
|
||||
args: {
|
||||
source: 'default',
|
||||
table: {
|
||||
name,
|
||||
schema,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const trackFunctions = functionEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_function',
|
||||
args: {
|
||||
source: 'default',
|
||||
function: {
|
||||
name,
|
||||
schema,
|
||||
configuration: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const metaDataPayload = {
|
||||
source: 'default',
|
||||
type: 'bulk',
|
||||
args: [...trackTablesOrViews, ...trackFunctions],
|
||||
};
|
||||
|
||||
try {
|
||||
if (entities.length > 0) {
|
||||
const metadataApiResponse = await fetch(`${appUrl}/v1/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify(metaDataPayload),
|
||||
});
|
||||
|
||||
if (!metadataApiResponse.ok) {
|
||||
throw new Error('Metadata API call failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error happened when calling the metadata API', {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runSQL = async () => {
|
||||
setLoading(true);
|
||||
setCommandOk(false);
|
||||
setErrorMessage('');
|
||||
|
||||
if (isMigration) {
|
||||
const { error: createMigrationError } = await createMigration(
|
||||
sqlCode,
|
||||
migrationName,
|
||||
cascade,
|
||||
);
|
||||
|
||||
setCommandOk(!createMigrationError);
|
||||
|
||||
if (createMigrationError) {
|
||||
setErrorMessage('An unknown error occurred');
|
||||
}
|
||||
|
||||
// if running the migration fails then we don't update the metadata
|
||||
if (track && !createMigrationError) {
|
||||
await updateMetadata(sqlCode);
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
result_type,
|
||||
error: $error,
|
||||
columns: $columns,
|
||||
rows: $rows,
|
||||
} = await sendSQLToHasura(sqlCode, cascade, readOnly);
|
||||
|
||||
setCommandOk(result_type === 'CommandOk');
|
||||
setColumns($columns);
|
||||
setRows($rows);
|
||||
setErrorMessage($error);
|
||||
|
||||
// if running the sql fails then we don't update the metadata
|
||||
if (track && !$error) {
|
||||
await updateMetadata(sqlCode);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return {
|
||||
runSQL,
|
||||
loading,
|
||||
errorMessage,
|
||||
commandOk,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetPostgresSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetPostgresSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -30,15 +32,6 @@ export type DatabaseServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_POSTGRES_VERSIONS = [
|
||||
'14.6-20230705-1',
|
||||
'14.6-20230613-1',
|
||||
'14.6-20230525',
|
||||
'14.6-20230406-2',
|
||||
'14.6-20230406-1',
|
||||
'14.6-20230404',
|
||||
];
|
||||
|
||||
export default function DatabaseServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -51,9 +44,16 @@ export default function DatabaseServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: databaseVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.PostgreSql,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.postgres || {};
|
||||
const databaseVersions = databaseVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_POSTGRES_VERSIONS).add(version),
|
||||
new Set(databaseVersions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
capacity: Yup.number().required(),
|
||||
});
|
||||
|
||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AuthDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch: refetchPostgresSettings,
|
||||
} = useGetPostgresSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const capacity =
|
||||
data?.config?.postgres?.resources?.storage?.capacity ??
|
||||
currentProject.plan.featureMaxDbSize;
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ capacity: number }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { capacity },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { formState, register, reset } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !loading) {
|
||||
reset({ capacity });
|
||||
}
|
||||
}, [loading, data, reset, capacity]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Auth Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: {
|
||||
storage: {
|
||||
capacity: formValues.capacity,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `Database storage capacity is being updated...`,
|
||||
success: `Database storage capacity has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the database storage capacity.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchPostgresSettings();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Storage capacity"
|
||||
description="Specify the storage capacity for your PostgreSQL database."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{currentProject.plan.isFree && (
|
||||
<UpgradeNotification message="Unlock by upgrading your project to the Pro plan." />
|
||||
)}
|
||||
<Box className="grid grid-flow-row lg:grid-cols-5">
|
||||
<Input
|
||||
{...register('capacity')}
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
fullWidth
|
||||
disabled={currentProject.plan.isFree}
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.capacity?.message)}
|
||||
helperText={formState.errors.capacity?.message}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: capacity,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!currentProject.plan.isFree && (
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
Note that volumes can only be increased (not decreased). Also, due
|
||||
to an AWS limitation, the same volume can only be increased once
|
||||
every 6 hours.
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseStorageCapacity } from './DatabaseStorageCapacity';
|
||||
@@ -4,11 +4,16 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
database
|
||||
}
|
||||
}
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
postgres {
|
||||
version
|
||||
resources {
|
||||
storage {
|
||||
capacity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetHasuraSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -30,16 +32,6 @@ export type HasuraServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_HASURA_VERSIONS = [
|
||||
'v2.29.0-ce',
|
||||
'v2.28.2-ce',
|
||||
'v2.27.0-ce',
|
||||
'v2.25.1-ce',
|
||||
'v2.25.0-ce',
|
||||
'v2.24.1-ce',
|
||||
'v2.15.2',
|
||||
];
|
||||
|
||||
export default function HasuraServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
@@ -53,9 +45,16 @@ export default function HasuraServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: hasuraVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Hasura,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.hasura || {};
|
||||
const versions = hasuraVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_HASURA_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetHasuraSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
hasura {
|
||||
@@ -18,6 +18,13 @@ query GetHasuraSettings($appId: uuid!) {
|
||||
events {
|
||||
httpPoolSize
|
||||
}
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useGetAnnouncementsQuery } from '@/utils/__generated__/graphql';
|
||||
import formatDistance from 'date-fns/formatDistance';
|
||||
|
||||
export default function Announcements() {
|
||||
const { data, loading, error } = useGetAnnouncementsQuery();
|
||||
|
||||
const announcements = data?.announcements || [];
|
||||
|
||||
if (loading || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Text color="secondary" className="mb-2">
|
||||
Latest announcements
|
||||
</Text>
|
||||
|
||||
<List className="relative space-y-4 border-l border-gray-200 dark:border-gray-700">
|
||||
{announcements.map((item) => (
|
||||
<ListItem.Root key={item.id} className="ml-4">
|
||||
<div className="flex flex-col">
|
||||
<time className="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500">
|
||||
{formatDistance(new Date(item.createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</time>
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||
<ListItem.Button
|
||||
dense
|
||||
aria-label={`View ${item.content}`}
|
||||
className="!p-1"
|
||||
>
|
||||
<p className="text-sm">{item.content}</p>
|
||||
</ListItem.Button>
|
||||
</a>
|
||||
</div>
|
||||
<div className="absolute top-[0.15rem] -ml-[1.4rem] h-3 w-3 rounded-full border border-white bg-gray-200 dark:border-gray-900 dark:bg-gray-700" />
|
||||
</ListItem.Root>
|
||||
))}
|
||||
</List>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Announcements } from './Announcements';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
@@ -99,7 +99,7 @@ export default function AppLoader({
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
@@ -250,7 +250,7 @@ export default function ApplicationErrored() {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
@@ -65,7 +65,7 @@ export default function ApplicationUnknown() {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { type RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteServiceModalProps {
|
||||
service: RunService;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteServiceModal({
|
||||
service,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteServiceModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async () => {
|
||||
await deleteRunService({ variables: { serviceID: service.id } });
|
||||
await deleteRunServiceConfig({
|
||||
variables: { appID: currentProject.id, serviceID: service.id },
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(),
|
||||
{
|
||||
loading: 'Deleting the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Service {service?.config?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this service?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete ${service?.config?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Project #1"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Service
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DeleteServiceModal';
|
||||
export { default as DeleteServiceModal } from './DeleteServiceModal';
|
||||
@@ -8,6 +8,7 @@ import { PlusCircleIcon } from '@/components/ui/v2/icons/PlusCircleIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Announcements } from '@/features/projects/common/components/Announcements';
|
||||
import { EditWorkspaceNameForm } from '@/features/projects/workspaces/components/EditWorkspaceNameForm';
|
||||
import type { Workspace } from '@/types/application';
|
||||
import Image from 'next/image';
|
||||
@@ -38,6 +39,8 @@ export default function WorkspaceSidebar({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Announcements />
|
||||
|
||||
<section className="grid grid-flow-row gap-2">
|
||||
<Text color="secondary">My Workspaces</Text>
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
awsName: null,
|
||||
domain: null,
|
||||
},
|
||||
isProvisioned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { SvgIconProps } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useHypertune } from '@/hooks/useHypertune';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export interface ProjectRoute {
|
||||
@@ -58,26 +57,8 @@ export interface ProjectRoute {
|
||||
export default function useProjectRoutes() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
loading: currentProjectLoading,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
const hypertune = useHypertune();
|
||||
|
||||
const enableServices =
|
||||
currentWorkspace &&
|
||||
hypertune
|
||||
.root({
|
||||
context: {
|
||||
workSpace: {
|
||||
id: currentWorkspace.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.enableServices({})
|
||||
.get(false);
|
||||
const { currentProject, loading: currentProjectLoading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const nhostRoutes: ProjectRoute[] = [
|
||||
{
|
||||
@@ -118,7 +99,7 @@ export default function useProjectRoutes() {
|
||||
},
|
||||
];
|
||||
|
||||
let allRoutes: ProjectRoute[] = [
|
||||
const allRoutes: ProjectRoute[] = [
|
||||
{
|
||||
relativePath: '/',
|
||||
exact: true,
|
||||
@@ -156,18 +137,15 @@ export default function useProjectRoutes() {
|
||||
label: 'Storage',
|
||||
icon: <StorageIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
if (enableServices) {
|
||||
allRoutes.push({
|
||||
{
|
||||
relativePath: '/services',
|
||||
exact: false,
|
||||
label: 'Run',
|
||||
icon: <ServicesIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
allRoutes = [...allRoutes, ...nhostRoutes];
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
return {
|
||||
nhostRoutes,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ProjectFragment } from '@/utils/__generated__/graphql';
|
||||
import { test, vi } from 'vitest';
|
||||
import generateAppServiceUrl, {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
} from './generateAppServiceUrl';
|
||||
|
||||
@@ -138,7 +137,7 @@ test('should be able to override the default remote backend slugs', () => {
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
|
||||
expect(
|
||||
generateAppServiceUrl('test', region, 'hasura', defaultLocalBackendSlugs, {
|
||||
generateAppServiceUrl('test', region, 'hasura', {
|
||||
...defaultRemoteBackendSlugs,
|
||||
hasura: '/lorem-ipsum',
|
||||
}),
|
||||
@@ -187,24 +186,3 @@ test('should construct service URLs based on environment variables', () => {
|
||||
'https://localdev4.nhost.run/v1/functions',
|
||||
);
|
||||
});
|
||||
|
||||
test('should generate a basic subdomain with a custom port if provided', () => {
|
||||
process.env.NEXT_PUBLIC_NHOST_BACKEND_URL = `http://localhost:1338`;
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'auth')).toBe(
|
||||
`http://localhost:1338/v1/auth`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'storage')).toBe(
|
||||
`http://localhost:1338/v1/files`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'graphql')).toBe(
|
||||
`http://localhost:1338/v1/graphql`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'functions')).toBe(
|
||||
`http://localhost:1338/v1/functions`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,6 @@ export default function generateAppServiceUrl(
|
||||
subdomain: string,
|
||||
region: ProjectFragment['region'],
|
||||
service: NhostService,
|
||||
localBackendSlugs = defaultLocalBackendSlugs,
|
||||
remoteBackendSlugs = defaultRemoteBackendSlugs,
|
||||
) {
|
||||
const IS_PLATFORM = isPlatform();
|
||||
@@ -87,12 +86,6 @@ export default function generateAppServiceUrl(
|
||||
return serviceUrls[service];
|
||||
}
|
||||
|
||||
// This is only used when running the dashboard locally against its own
|
||||
// backend.
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return `${process.env.NEXT_PUBLIC_NHOST_BACKEND_URL}${localBackendSlugs[service]}`;
|
||||
}
|
||||
|
||||
const constructedDomain = [
|
||||
subdomain,
|
||||
service,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
auth_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AuthDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ auth_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { auth_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.auth?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ auth_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Auth Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const auth_fqdn = watch('auth_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.auth_fqdn.length > 0 ? [{ fqdn: [formValues.auth_fqdn] }] : [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Auth domain is being updated...`,
|
||||
success: `Auth domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the auth domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Auth Domain"
|
||||
description="Enter below your custom domain for the authentication service."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('auth_fqdn')}
|
||||
id="auth_fqdn"
|
||||
name="auth_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="auth.mydomain.dev"
|
||||
error={Boolean(formState.errors.auth_fqdn?.message)}
|
||||
helperText={formState.errors.auth_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={auth_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AuthDomain } from './AuthDomain';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import { useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
database_fqdn: Yup.string().required(),
|
||||
});
|
||||
|
||||
export type DatabaseDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function DatabaseDomain() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [dbFQDN, setDbFQDN] = useState('');
|
||||
|
||||
const postgresHost = generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'db',
|
||||
).replace('https://', '');
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Database Domain"
|
||||
description="Enter below your custom domain for the PostgreSQL database to verify. Once verified there is no need to save this value as no configuration on our end is required."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
hidden: true,
|
||||
},
|
||||
footer: {
|
||||
className: 'hidden',
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
id="database_fqdn"
|
||||
name="database_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="db.mydomain.dev"
|
||||
onChange={(e) => {
|
||||
setDbFQDN(e.target.value);
|
||||
}}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={dbFQDN}
|
||||
value={`${postgresHost}.`}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseDomain } from './DatabaseDomain';
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
hasura_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type HasuraDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ hasura_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { hasura_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.hasura?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ hasura_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={0}
|
||||
label="Loading Hasura Domain..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const hasura_fqdn = watch('hasura_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: HasuraDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.hasura_fqdn.length > 0
|
||||
? [{ fqdn: [formValues.hasura_fqdn] }]
|
||||
: [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Hasura domain is being updated...`,
|
||||
success: `Hasura domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the Hasura domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Hasura Domain"
|
||||
description="Enter below your custom domain for the Hasura/GraphQL service."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('hasura_fqdn')}
|
||||
id="hasura_fqdn"
|
||||
name="hasura_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="auth.mydomain.dev"
|
||||
error={Boolean(formState.errors.hasura_fqdn?.message)}
|
||||
helperText={formState.errors.hasura_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={hasura_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HasuraDomain } from './HasuraDomain';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { RunServicePortDomain } from '@/features/projects/custom-domains/settings/components/RunServicePortDomain';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function RunServiceDomains() {
|
||||
const { currentProject, currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
// refetch: refetchServices, // TODO refetch after update
|
||||
} = useGetRunServicesQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
resolve: false,
|
||||
limit: 1000, // TODO consider pagination
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const services = useMemo(
|
||||
() => data?.app?.runServices.map((service) => service) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Run Services Domains..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{services
|
||||
.filter((service) => service.config?.ports?.length > 0)
|
||||
.map((service) => (
|
||||
<SettingsContainer
|
||||
key={service.id}
|
||||
title={
|
||||
<div className="flex flex-row items-center">
|
||||
<Text className="text-lg font-semibold">
|
||||
{service.config?.name ?? 'unset'}
|
||||
</Text>
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentProject.slug}/services`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<ArrowSquareOutIcon className="mb-1 ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
description="Enter below your custom domain for the published ports."
|
||||
docsTitle={service.config?.name ?? 'unset'}
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
hidden: true,
|
||||
},
|
||||
footer: {
|
||||
className: 'hidden',
|
||||
},
|
||||
}}
|
||||
className="grid gap-y-4 gap-x-4 px-4"
|
||||
>
|
||||
{service.config?.ports?.map((port) => (
|
||||
<RunServicePortDomain
|
||||
key={String(port.port)}
|
||||
service={service}
|
||||
port={port.port}
|
||||
/>
|
||||
))}
|
||||
</SettingsContainer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunServiceDomains } from './RunServiceDomains';
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import { useUpdateRunServiceConfigMutation } from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { type RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
interface RunServicePortProps {
|
||||
service: RunService;
|
||||
port: number;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
runServicePortFQDN: Yup.string(),
|
||||
});
|
||||
|
||||
export type RunServicePortFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function RunServicePortDomain({
|
||||
service,
|
||||
port,
|
||||
}: RunServicePortProps) {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateRunServiceConfig] = useUpdateRunServiceConfigMutation();
|
||||
|
||||
const runServicePort = service.config.ports.find((p) => p.port === port);
|
||||
const initialValue = runServicePort?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
const form = useForm<{ runServicePortFQDN: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
runServicePortFQDN: initialValue,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const runServicePortFQDN = watch('runServicePortFQDN');
|
||||
|
||||
async function handleSubmit(formValues: RunServicePortFormValues) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
await toast.promise(
|
||||
updateRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID: service.id,
|
||||
config: {
|
||||
ports: service?.config?.ports?.map((p) => {
|
||||
// exclude the `__typename` because the mutation will fail otherwise
|
||||
const { __typename, ...rest } = p;
|
||||
|
||||
if (rest.port === port) {
|
||||
return {
|
||||
...rest,
|
||||
ingresses:
|
||||
formValues.runServicePortFQDN.length > 0
|
||||
? [{ fqdn: [formValues.runServicePortFQDN] }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
// exclude the `__typename` because the mutation will fail otherwise
|
||||
ingresses: rest.ingresses.map((item) => ({
|
||||
fqdn: item.fqdn,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `Port ${port} is being updated...`,
|
||||
success: `Port ${port} has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update Port ${port}.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
// TODO refetch the service config
|
||||
// await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Text className="text-sm font-semibold">{`${runServicePort.type} <--> ${runServicePort.port}`}</Text>
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Input
|
||||
{...register('runServicePortFQDN')}
|
||||
id="runServicePortFQDN"
|
||||
name="runServicePortFQDN"
|
||||
type="string"
|
||||
fullWidth
|
||||
className=""
|
||||
placeholder={`${service.config?.name ?? 'unset'}-${
|
||||
runServicePort.port
|
||||
}.mydomain.dev`}
|
||||
error={Boolean(formState.errors.runServicePortFQDN?.message)}
|
||||
helperText={formState.errors.runServicePortFQDN?.message}
|
||||
slotProps={{
|
||||
inputRoot: { min: 1, max: 100 },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
type="submit"
|
||||
disabled={
|
||||
loading ||
|
||||
!isDirty ||
|
||||
maintenanceActive ||
|
||||
(!isVerified && !initialValue)
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5 row-start-2 mt-4">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={runServicePortFQDN}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunServicePortDomain } from './RunServicePortDomain';
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetServerlessFunctionsSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
functions_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type ServerlessFunctionsDomainFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function ServerlessFunctionsDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ functions_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { functions_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetServerlessFunctionsSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.functions?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ functions_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Serverless Functions Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const functions_fqdn = watch('functions_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: ServerlessFunctionsDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.functions_fqdn.length > 0
|
||||
? [{ fqdn: [formValues.functions_fqdn] }]
|
||||
: [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
functions: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Serverless Functions domain is being updated...`,
|
||||
success: `Serverless Functions domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the Serverless Functions domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Serverless Functions Domain"
|
||||
description="Enter below your custom domain for Serverless Functions."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('functions_fqdn')}
|
||||
id="functions_fqdn"
|
||||
name="functions_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="functions.mydomain.dev"
|
||||
error={Boolean(formState.errors.functions_fqdn?.message)}
|
||||
helperText={formState.errors.functions_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={functions_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServerlessFunctionsDomain } from './ServerlessFunctionsDomain';
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { useDnsLookupCnameLazyQuery } from '@/utils/__generated__/graphql';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface VerifyDomainProps {
|
||||
recordType: string;
|
||||
hostname: string;
|
||||
value: string;
|
||||
onHostNameVerified?: () => void;
|
||||
}
|
||||
|
||||
export default function VerifyDomain({
|
||||
recordType,
|
||||
hostname,
|
||||
value,
|
||||
onHostNameVerified,
|
||||
}: VerifyDomainProps) {
|
||||
const [verificationFailed, setVerificationFailed] = useState(false);
|
||||
const [verificationSucceeded, setVerificationSucceeded] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fireLookupCNAME] = useDnsLookupCnameLazyQuery();
|
||||
|
||||
const handleVerifyDomain = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
fireLookupCNAME({
|
||||
variables: {
|
||||
hostname,
|
||||
},
|
||||
}).then(({ data: { dnsLookupCNAME } }) => {
|
||||
if (dnsLookupCNAME !== value) {
|
||||
throw new Error(`Could not verify ${hostname}`);
|
||||
}
|
||||
}),
|
||||
{
|
||||
loading: `Verifying ${hostname} ...`,
|
||||
success: () => {
|
||||
setVerificationFailed(false);
|
||||
setVerificationSucceeded(true);
|
||||
setLoading(false);
|
||||
onHostNameVerified?.();
|
||||
return `${hostname} has been verified.`;
|
||||
},
|
||||
error: (arg: Error | ApolloError) => {
|
||||
setVerificationFailed(true);
|
||||
setVerificationSucceeded(false);
|
||||
setLoading(false);
|
||||
|
||||
if (arg instanceof ApolloError) {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } =
|
||||
(internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
`An error occurred while trying to verify ${hostname}. Please try again.`
|
||||
);
|
||||
}
|
||||
|
||||
return arg.message;
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch (error) {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={[
|
||||
{ backgroundColor: 'primary.light' },
|
||||
verificationFailed && {
|
||||
backgroundColor: 'error.light',
|
||||
color: 'error.main',
|
||||
},
|
||||
verificationSucceeded && {
|
||||
backgroundColor: 'success.light',
|
||||
color: 'success.dark',
|
||||
},
|
||||
]}
|
||||
className="flex flex-col p-4 space-y-4 rounded-md"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
{!verificationFailed && !verificationSucceeded && (
|
||||
<Text>
|
||||
Add the record below in your DNS provider to verify {hostname}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{verificationSucceeded && (
|
||||
<Text>
|
||||
<span className="font-semibold">{hostname}</span> was verified
|
||||
successfully. Hit save to apply.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{verificationFailed && (
|
||||
<Text>
|
||||
An error occurred while trying to verify{' '}
|
||||
<span className="font-semibold">{hostname}</span>. Make sure you
|
||||
correctly added the <span className="font-semibold">CNAME</span> and
|
||||
try again.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col text-slate-500">
|
||||
<div className="flex space-x-2">
|
||||
<Text>Record type: </Text>
|
||||
<Text className="font-bold">{recordType}</Text>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Text>Host:</Text>
|
||||
<Text className="font-bold">{hostname}</Text>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Text>Value:</Text>
|
||||
<Text className="font-bold">{value}</Text>
|
||||
<IconButton
|
||||
aria-label="Copy Personal Access Token"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={() => copy(value, 'CNAME Value')}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Button
|
||||
disabled={loading || !hostname}
|
||||
onClick={handleVerifyDomain}
|
||||
className="mt-4 sm:absolute sm:bottom-0 sm:right-0 sm:mt-0"
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VerifyDomain } from './VerifyDomain';
|
||||
@@ -128,6 +128,8 @@ export default function BaseEnvironmentVariableForm({
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useAppClient } from '@/features/projects/common/hooks/useAppClient';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
generateAppServiceUrl,
|
||||
} from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
@@ -110,7 +109,6 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
defaultLocalBackendSlugs,
|
||||
{ ...defaultRemoteBackendSlugs, hasura: '/console' },
|
||||
),
|
||||
},
|
||||
|
||||
@@ -41,11 +41,6 @@ export default function OverviewMetrics() {
|
||||
numberOfDecimals: 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Egress Volume',
|
||||
tooltip: 'Amount of data your services have sent to users',
|
||||
value: prettifySize(data?.egressVolume?.value || 0),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
tooltip: 'Amount of logs stored',
|
||||
|
||||
@@ -96,7 +96,7 @@ export function OverviewUsageMetrics() {
|
||||
remoteAppMetricsData?.filesAggregate?.aggregate?.sum?.size || 0;
|
||||
const totalStorage = currentProject?.plan?.isFree
|
||||
? 1 * 1000 ** 3 // 1 GB
|
||||
: 10 * 1000 ** 3; // 10 GB
|
||||
: 50 * 1000 ** 3; // 10 GB
|
||||
|
||||
// metrics for users
|
||||
const usedUsers = remoteAppMetricsData?.usersAggregate?.aggregate?.count || 0;
|
||||
@@ -105,6 +105,16 @@ export function OverviewUsageMetrics() {
|
||||
// metrics for functions
|
||||
const usedFunctions = functionsInfoData?.app.metadataFunctions.length || 0;
|
||||
const totalFunctions = currentProject?.plan?.isFree ? 10 : 50;
|
||||
const usedFunctionsDuration = projectMetrics?.functionsDuration.value || 0;
|
||||
const totalFunctionsDuration = currentProject?.plan?.isFree
|
||||
? 3600 // 1 hour
|
||||
: 3600 * 10; // 10 hours
|
||||
|
||||
// metrics for egress
|
||||
const usedEgressVolume = projectMetrics?.egressVolume.value || 0;
|
||||
const totalEgressVolume = currentProject?.plan?.isFree
|
||||
? 5 * 1000 ** 3 // 5 GB
|
||||
: 50 * 1000 ** 3; // 50 GB
|
||||
|
||||
if (metricsLoading) {
|
||||
return (
|
||||
@@ -112,7 +122,9 @@ export function OverviewUsageMetrics() {
|
||||
<UsageProgress label="Database" percentage={0} />
|
||||
<UsageProgress label="Storage" percentage={0} />
|
||||
<UsageProgress label="Users" percentage={0} />
|
||||
<UsageProgress label="Functions" percentage={0} />
|
||||
<UsageProgress label="Number of Functions" percentage={0} />
|
||||
<UsageProgress label="Functions Execution Time" percentage={0} />
|
||||
<UsageProgress label="Egress Volume" percentage={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,6 +151,18 @@ export function OverviewUsageMetrics() {
|
||||
used={usedFunctions}
|
||||
percentage={100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions"
|
||||
used={usedFunctionsDuration}
|
||||
percentage={100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Egress"
|
||||
used={usedEgressVolume}
|
||||
percentage={100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,11 +191,25 @@ export function OverviewUsageMetrics() {
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions"
|
||||
label="Number of Functions"
|
||||
used={usedFunctions}
|
||||
total={totalFunctions}
|
||||
percentage={(usedFunctions / totalFunctions) * 100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions Execution Time"
|
||||
used={Math.trunc(usedFunctionsDuration)}
|
||||
total={`${totalFunctionsDuration} seconds`}
|
||||
percentage={(usedFunctionsDuration / totalFunctionsDuration) * 100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Egress Volume"
|
||||
used={prettifySize(usedEgressVolume)}
|
||||
total={prettifySize(totalEgressVolume)}
|
||||
percentage={(usedEgressVolume / totalEgressVolume) * 100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { Slider, sliderClasses } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useProPlan } from '@/features/projects/common/hooks/useProPlan';
|
||||
import { calculateBillableResources } from '@/features/projects/resources/settings/utils/calculateBillableResources';
|
||||
import { getAllocatedResources } from '@/features/projects/resources/settings/utils/getAllocatedResources';
|
||||
import { prettifyMemory } from '@/features/projects/resources/settings/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/projects/resources/settings/utils/prettifyVCPU';
|
||||
@@ -63,34 +62,7 @@ export default function TotalResourcesFormFragment({
|
||||
(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 updatedPrice = priceForTotalAvailableVCPU + proPlan.price;
|
||||
|
||||
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
|
||||
getAllocatedResources(formValues);
|
||||
|
||||
@@ -38,7 +38,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
}
|
||||
|
||||
query GetResources($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
...ServiceResources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
query GetServerlessFunctionsSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
functions {
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
@@ -43,6 +44,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { parse } from 'shell-quote';
|
||||
import * as Yup from 'yup';
|
||||
import { ServiceConfirmationDialog } from './components/ServiceConfirmationDialog';
|
||||
import { ServiceDetailsDialog } from './components/ServiceDetailsDialog';
|
||||
|
||||
export enum PortTypes {
|
||||
HTTP = 'http',
|
||||
@@ -94,7 +96,7 @@ export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: ServiceFormValues;
|
||||
initialData?: ServiceFormValues & { subdomain?: string }; // subdomain is only set on the backend
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
@@ -119,6 +121,10 @@ export default function ServiceForm({
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation();
|
||||
const [detailsServiceId, setDetailsServiceId] = useState('');
|
||||
const [detailsServiceSubdomain, setDetailsServiceSubdomain] = useState(
|
||||
initialData?.subdomain,
|
||||
);
|
||||
|
||||
const [createServiceFormError, setCreateServiceFormError] =
|
||||
useState<Error | null>(null);
|
||||
@@ -196,11 +202,13 @@ export default function ServiceForm({
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(serviceID);
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID },
|
||||
insertRunService: { id: newServiceID, subdomain },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
@@ -227,6 +235,9 @@ export default function ServiceForm({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(newServiceID);
|
||||
setDetailsServiceSubdomain(subdomain);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,8 +265,6 @@ export default function ServiceForm({
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
// await refetchWorkspaceAndProject();
|
||||
// refestch the services
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
@@ -277,6 +286,28 @@ export default function ServiceForm({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (detailsServiceId) {
|
||||
openDialog({
|
||||
title: 'Service Details',
|
||||
component: (
|
||||
<ServiceDetailsDialog
|
||||
serviceID={detailsServiceId}
|
||||
subdomain={detailsServiceSubdomain}
|
||||
ports={formValues.ports}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [detailsServiceId, detailsServiceSubdomain, formValues, openDialog]);
|
||||
|
||||
const pricingExplanation = () => {
|
||||
const vCPUs = `${formValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER} vCPUs`;
|
||||
const mem = `${formValues.compute.memory} MiB Mem`;
|
||||
@@ -314,7 +345,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -354,7 +385,7 @@ export default function ServiceForm({
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -385,7 +416,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -429,7 +460,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
@@ -452,7 +483,7 @@ export default function ServiceForm({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<PlusIcon />}
|
||||
startIcon={serviceID ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{serviceID ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -32,20 +32,20 @@ export default function PortsFormSection() {
|
||||
name: 'ports',
|
||||
});
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
const formValues = useWatch<ServiceFormValues & { subdomain: string }>();
|
||||
|
||||
const onChangePortType = (value: string | undefined, index: number) =>
|
||||
setValue(`ports.${index}.type`, value as PortTypes);
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.subdomain &&
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, _name: string) => {
|
||||
const getPortURL = (_port: string | number, subdomain: string) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
const name = _name && _name.length > 0 ? _name : '[name]';
|
||||
|
||||
return `https://${currentProject?.subdomain}-${name}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -144,7 +144,7 @@ export default function PortsFormSection() {
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.name,
|
||||
formValues.subdomain,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import type { ConfigRunServicePort } from '@/utils/__generated__/graphql';
|
||||
|
||||
export interface ServiceDetailsDialogProps {
|
||||
/**
|
||||
* The id of the service
|
||||
*/
|
||||
serviceID: string;
|
||||
|
||||
/**
|
||||
* The subdomain of the service
|
||||
*/
|
||||
subdomain: string;
|
||||
|
||||
/**
|
||||
* The service ports
|
||||
* We use partial here because `port` is set as required in ConfigRunServicePort
|
||||
*/
|
||||
ports: Partial<ConfigRunServicePort>[];
|
||||
}
|
||||
|
||||
export default function ServiceDetailsDialog({
|
||||
serviceID,
|
||||
subdomain,
|
||||
ports,
|
||||
}: ServiceDetailsDialogProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const getPortURL = (_port: string | number) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-6 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Private registry</Text>
|
||||
<InfoCard
|
||||
title=""
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ports?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Ports</Text>
|
||||
{ports
|
||||
.filter((port) => port.publish)
|
||||
.map((port) => (
|
||||
<InfoCard
|
||||
key={String(port.port)}
|
||||
title={`${port.type} <--> ${port.port}`}
|
||||
value={getPortURL(port.port)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
color="primary"
|
||||
onClick={() => closeDialog()}
|
||||
autoFocus
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceDetailsDialog';
|
||||
export { default as ServiceDetailsDialog } from './ServiceDetailsDialog';
|
||||
@@ -10,21 +10,14 @@ import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { DeleteServiceModal } from '@/features/projects/common/components/DeleteServiceModal';
|
||||
import {
|
||||
ServiceForm,
|
||||
type PortTypes,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ServicesListProps {
|
||||
/**
|
||||
@@ -51,16 +44,7 @@ export default function ServicesList({
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: ServicesListProps) {
|
||||
const { openDrawer } = useDialog();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async (appID: string, serviceID: string) => {
|
||||
await deleteRunService({ variables: { serviceID } });
|
||||
await deleteRunServiceConfig({ variables: { appID, serviceID } });
|
||||
await onDelete?.();
|
||||
};
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewService = async (service: RunService) => {
|
||||
openDrawer({
|
||||
@@ -76,6 +60,7 @@ export default function ServicesList({
|
||||
initialData={{
|
||||
...service.config,
|
||||
image: service.config?.image?.image,
|
||||
subdomain: service.subdomain,
|
||||
command: service.config?.command?.join(' '),
|
||||
ports: service.config?.ports?.map((item) => ({
|
||||
port: item.port,
|
||||
@@ -95,28 +80,16 @@ export default function ServicesList({
|
||||
});
|
||||
};
|
||||
|
||||
const deleteService = async (serviceID: string) => {
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(currentProject.id, serviceID),
|
||||
{
|
||||
loading: 'Deleteing the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
const deleteService = async (service: RunService) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteServiceModal
|
||||
service={service}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -203,7 +176,7 @@ export default function ServicesList({
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteService(service.id)}
|
||||
onClick={() => deleteService(service)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
|
||||
@@ -7,6 +7,8 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetStorageSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useGetStorageSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
@@ -30,8 +32,6 @@ export type StorageServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_STORAGE_VERSIONS = ['0.3.5', '0.3.4', '0.3.3', '0.3.2'];
|
||||
|
||||
export default function StorageServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -44,9 +44,16 @@ export default function StorageServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: storageVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Storage,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.storage || {};
|
||||
const versions = storageVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_STORAGE_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetStorageSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
storage {
|
||||
|
||||
12
dashboard/src/gql/app/getProjectLocales.graphql
Normal file
12
dashboard/src/gql/app/getProjectLocales.graphql
Normal file
@@ -0,0 +1,12 @@
|
||||
query getProjectLocales($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
auth {
|
||||
user {
|
||||
locale {
|
||||
allowed
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ query GetProjectMetrics(
|
||||
) {
|
||||
value
|
||||
}
|
||||
functionsDuration: getFunctionsDuration(appID: $appId, from: $from, to: $to) {
|
||||
value
|
||||
}
|
||||
postgresVolumeCapacity: getPostgresVolumeCapacity(appID: $appId) {
|
||||
value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
query dnsLookupCNAME($hostname: String!) {
|
||||
dnsLookupCNAME(hostname: $hostname)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ fragment JWTSecret on ConfigJWTSecret {
|
||||
}
|
||||
|
||||
query GetEnvironmentVariables($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
global {
|
||||
|
||||
@@ -5,7 +5,7 @@ fragment PermissionVariable on ConfigAuthsessionaccessTokenCustomClaims {
|
||||
}
|
||||
|
||||
query GetRolesPermissions($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
auth {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetSignInMethods($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
provider {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetSmtpSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
provider {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user