Compare commits
473 Commits
@nhost/nex
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0da84bbec | ||
|
|
5efa43aa2e | ||
|
|
2497194dcc | ||
|
|
5733162ed6 | ||
|
|
ab106c9492 | ||
|
|
4d2aac807c | ||
|
|
a659760724 | ||
|
|
13086bcae3 | ||
|
|
86459468be | ||
|
|
34cec77ceb | ||
|
|
abfb42651a | ||
|
|
ec584181cc | ||
|
|
70b31358bc | ||
|
|
521f418f8c | ||
|
|
8851416e7a | ||
|
|
f98f5a4bca | ||
|
|
650a605b61 | ||
|
|
422e1bbeae | ||
|
|
367e86abd2 | ||
|
|
7e172d6352 | ||
|
|
e786a6fa84 | ||
|
|
f899f4000d | ||
|
|
ecd27f34d6 | ||
|
|
9f488d2739 | ||
|
|
fac066c0cd | ||
|
|
fdc56e9611 | ||
|
|
7b11f343ac | ||
|
|
04d39bef90 | ||
|
|
83e21f879f | ||
|
|
8e26cdb5ed | ||
|
|
4dc1a5ded3 | ||
|
|
b3f6c732dd | ||
|
|
a63342d0bd | ||
|
|
4913ff7a8b | ||
|
|
99cbbbcbf9 | ||
|
|
3a11b6a8fa | ||
|
|
be4b26c65d | ||
|
|
33df3c842d | ||
|
|
a5bba46b59 | ||
|
|
1358a41dc4 | ||
|
|
2b7cf59159 | ||
|
|
083c65b775 | ||
|
|
1c940469fb | ||
|
|
e2bf1118f9 | ||
|
|
9a1ad43370 | ||
|
|
e2b79b5ece | ||
|
|
c47d47ac9c | ||
|
|
926590acb5 | ||
|
|
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 | ||
|
|
72c81207ff | ||
|
|
5ca2a394e8 | ||
|
|
e63b8da58a | ||
|
|
bf8543cd34 | ||
|
|
8a557bbd02 | ||
|
|
327e30b859 | ||
|
|
bbfaf9732b | ||
|
|
c064a53256 | ||
|
|
ebda86f1f0 | ||
|
|
8948be9d3d | ||
|
|
54e9b141f1 | ||
|
|
dba71483df | ||
|
|
77ef68232a | ||
|
|
8fbc7f9f95 | ||
|
|
ca9f0f6ae9 | ||
|
|
e819903f1b | ||
|
|
f780b17581 | ||
|
|
032c0bd217 | ||
|
|
5d278709cb | ||
|
|
3a012e089a | ||
|
|
7aed620e12 | ||
|
|
dd0a5cf3c1 | ||
|
|
5187fd3a4b | ||
|
|
d8dfd6bf80 | ||
|
|
6ea6ad61db | ||
|
|
fd0b904ed4 | ||
|
|
8989e314a6 | ||
|
|
5b5a1219c5 | ||
|
|
2fa828fef1 | ||
|
|
d5ec69ac37 | ||
|
|
09fc852c3a | ||
|
|
27e1c90624 | ||
|
|
1cc53d550a | ||
|
|
22d3f71e02 | ||
|
|
010b816866 | ||
|
|
4a6e62e673 |
@@ -5,6 +5,5 @@
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
"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
.gitignore
vendored
3
.gitignore
vendored
@@ -19,10 +19,8 @@ logs/
|
||||
coverage/
|
||||
dist/
|
||||
umd/
|
||||
lib/
|
||||
node_modules/
|
||||
tmp/
|
||||
.docz/
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.env
|
||||
@@ -32,7 +30,6 @@ out/
|
||||
# Custom
|
||||
*.min.js
|
||||
*.map
|
||||
todo.md
|
||||
|
||||
# Config files that are not part of the repository root anymore. Should be removed in the future.
|
||||
/.eslintignore
|
||||
|
||||
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,190 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5733162ed: feat: add settings and ui for graphite
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e2b79b5ec: chore: remove sharp from deps
|
||||
|
||||
## 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
|
||||
|
||||
- @nhost/react-apollo@5.0.34
|
||||
- @nhost/nextjs@1.13.36
|
||||
|
||||
## 0.20.7
|
||||
|
||||
### 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,
|
||||
13,
|
||||
);
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /overview/i }),
|
||||
|
||||
14
dashboard/graphite.graphql.config.yaml
Normal file
14
dashboard/graphite.graphql.config.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
schema:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
generates:
|
||||
src/utils/__generated__/graphite.graphql.ts:
|
||||
documents:
|
||||
- 'src/gql/graphite/**/*.gql'
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
- 'typescript-react-apollo'
|
||||
config:
|
||||
withRefetchFn: true
|
||||
@@ -7,6 +7,7 @@ generates:
|
||||
documents:
|
||||
- 'src/**/*.graphql'
|
||||
- 'src/**/*.gql'
|
||||
- '!src/gql/graphite/**/*.gql'
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.7",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "next lint --max-warnings 0",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"codegen-graphite": "graphql-codegen --config graphite.graphql.config.yaml --errors-only",
|
||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
@@ -19,7 +20,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 +45,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",
|
||||
@@ -62,6 +65,7 @@
|
||||
"node-pg-format": "^1.3.5",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-children-utilities": "^2.9.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
@@ -69,10 +73,14 @@
|
||||
"react-intersection-observer": "^9.5.2",
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-resizable-layout": "^0.7.2",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
"recoil": "^0.7.7",
|
||||
"recoil-persist": "^5.1.0",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.5",
|
||||
"stripe": "^10.17.0",
|
||||
@@ -100,12 +108,14 @@
|
||||
"@storybook/manager-webpack5": "^6.5.14",
|
||||
"@storybook/react": "^6.5.14",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/ace": "^0.0.48",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.30",
|
||||
@@ -115,8 +125,8 @@
|
||||
"@types/shell-quote": "^1.7.1",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
|
||||
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,103 @@
|
||||
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';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
interface UpgradeToProBannerProps {
|
||||
title: string;
|
||||
description: string | ReactNode;
|
||||
}
|
||||
|
||||
export default function UpgradeToProBanner({
|
||||
title,
|
||||
description,
|
||||
}: UpgradeToProBannerProps) {
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
className="flex flex-col justify-between space-y-4 rounded-md p-4 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>
|
||||
{typeof description === 'string' ? (
|
||||
<Text>{description}</Text>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</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="text-center font-medium"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
See all features
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Image
|
||||
src="/illustration-unbox.png"
|
||||
width={300}
|
||||
height={140}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UpgradeToProBanner } from './UpgradeToProBanner';
|
||||
46
dashboard/src/components/layout/AILayout/AILayout.tsx
Normal file
46
dashboard/src/components/layout/AILayout/AILayout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AISidebar } from '@/components/layout/AISidebar';
|
||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AILayoutProps extends ProjectLayoutProps {
|
||||
/**
|
||||
* Props passed to the sidebar component.
|
||||
*/
|
||||
sidebarProps?: SettingsSidebarProps;
|
||||
}
|
||||
|
||||
export default function AILayout({
|
||||
children,
|
||||
mainContainerProps: {
|
||||
className: mainContainerClassName,
|
||||
...mainContainerProps
|
||||
} = {},
|
||||
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
|
||||
...props
|
||||
}: AILayoutProps) {
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{
|
||||
className: twMerge('flex h-full', mainContainerClassName),
|
||||
...mainContainerProps,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AISidebar
|
||||
className={twMerge('w-full max-w-sidebar', sidebarClassName)}
|
||||
{...sidebarProps}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/layout/AILayout/index.ts
Normal file
2
dashboard/src/components/layout/AILayout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AILayout';
|
||||
export { default as SettingsLayout } from './AILayout';
|
||||
143
dashboard/src/components/layout/AISidebar/AISidebar.tsx
Normal file
143
dashboard/src/components/layout/AISidebar/AISidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AISidebarProps extends Omit<BoxProps, 'children'> {}
|
||||
|
||||
interface AINavLinkProps extends ListItemButtonProps {
|
||||
/**
|
||||
* Link to navigate to.
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
function AINavLink({ exact = true, href, children, ...props }: AINavLinkProps) {
|
||||
const router = useRouter();
|
||||
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}/ai`;
|
||||
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
|
||||
|
||||
const active = exact
|
||||
? router.asPath === finalUrl
|
||||
: router.asPath.startsWith(finalUrl);
|
||||
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
dense
|
||||
href={finalUrl}
|
||||
component={NavLink}
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Text>{children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function closeSidebarWhenEscapeIsPressed(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}
|
||||
|
||||
return () =>
|
||||
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}, []);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop
|
||||
open={expanded}
|
||||
className="absolute top-0 left-0 bottom-0 right-0 z-[34] md:hidden"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Close sidebar overlay"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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 md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<nav aria-label="Settings navigation">
|
||||
<List className="grid gap-2">
|
||||
<AINavLink
|
||||
href="/auto-embeddings"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Auto-Embeddings
|
||||
</AINavLink>
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
|
||||
onClick={toggleExpanded}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
src="/assets/table.svg"
|
||||
alt="A monochrome table"
|
||||
/>
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/layout/AISidebar/index.ts
Normal file
2
dashboard/src/components/layout/AISidebar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AISidebar';
|
||||
export { default as AISidebar } from './AISidebar';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
|
||||
@@ -6,14 +7,19 @@ import { LocalAccountMenu } from '@/components/layout/LocalAccountMenu';
|
||||
import { MobileNav } from '@/components/layout/MobileNav';
|
||||
import { Logo } from '@/components/presentational/Logo';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Chip } from '@/components/ui/v2/Chip';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { DevAssistant } from '@/features/ai/DevAssistant';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { DetailedHTMLProps, HTMLProps, PropsWithoutRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface HeaderProps
|
||||
@@ -23,9 +29,14 @@ export interface HeaderProps
|
||||
|
||||
export default function Header({ className, ...props }: HeaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { openDrawer } = useDialog();
|
||||
|
||||
const { currentProject, refetch: refetchProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const isProjectUpdating =
|
||||
currentProject?.appStates[0]?.stateId === ApplicationStatus.Updating;
|
||||
|
||||
@@ -44,6 +55,23 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
};
|
||||
}, [isProjectUpdating, refetchProject]);
|
||||
|
||||
const openDevAssistant = () => {
|
||||
// The dev assistant can be only answer questions related to a particular project
|
||||
if (!currentProject) {
|
||||
toast.error('You need to be inside a project to open the Assistant', {
|
||||
style: getToastStyleProps().style,
|
||||
...getToastStyleProps().error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openDrawer({
|
||||
title: <GraphiteIcon />,
|
||||
component: <DevAssistant />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
@@ -54,7 +82,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-flow-col items-center gap-3 ">
|
||||
<div className="grid grid-flow-col items-center gap-3">
|
||||
<NavLink href="/" className="w-12">
|
||||
<Logo className="mx-auto cursor-pointer" />
|
||||
</NavLink>
|
||||
@@ -69,20 +97,24 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-flow-col items-center gap-2 sm:grid">
|
||||
<Button className="rounded-full" onClick={openDevAssistant}>
|
||||
<GraphiteIcon />
|
||||
</Button>
|
||||
|
||||
{isPlatform && (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
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,17 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Secrets
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/custom-domains"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Custom Domains
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink href="/ai" exact={false} onClick={handleSelect}>
|
||||
AI
|
||||
</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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
24
dashboard/src/components/ui/v2/icons/AIIcon/AIIcon.tsx
Normal file
24
dashboard/src/components/ui/v2/icons/AIIcon/AIIcon.tsx
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/src/components/ui/v2/icons/AIIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/AIIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AIIcon } from './AIIcon';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ArrowElbowRightUp(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 6L11 3L14 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12H11V3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ArrowElbowRightUp.displayName = 'NhostArrowElbowRightUp';
|
||||
|
||||
export default ArrowElbowRightUp;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ArrowElbowRightUp } from './ArrowElbowRightUp';
|
||||
@@ -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,33 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function EmbeddingsIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="17"
|
||||
height="17"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 17 17"
|
||||
fill="none"
|
||||
aria-label="Embeddings Icon"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.178057 4.04687L4.04687 0.178057C4.28428 -0.0593522 4.6692 -0.0593522 4.90661 0.178057L8.77542 4.04687C9.01283 4.28428 9.01283 4.6692 8.77542 4.90661C8.53801 5.14402 8.15309 5.14402 7.91568 4.90661L5.08466 2.07559L5.08466 12.7664H3.86881L3.86881 2.07559L1.03779 4.90661C0.800384 5.14402 0.415467 5.14402 0.178057 4.90661C-0.0593524 4.6692 -0.0593524 4.28428 0.178057 4.04687Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.9531 8.22458L16.8219 12.0934C17.0594 12.3308 17.0594 12.7157 16.8219 12.9531L12.9531 16.8219C12.7157 17.0594 12.3308 17.0594 12.0934 16.8219C11.856 16.5845 11.856 16.1996 12.0934 15.9622L14.9244 13.1312H4.23357V11.9153H14.9244L12.0934 9.08432C11.856 8.84691 11.856 8.46199 12.0934 8.22458C12.3308 7.98717 12.7157 7.98717 12.9531 8.22458Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
EmbeddingsIcon.displayName = 'NhostEmbeddingsIcon';
|
||||
|
||||
export default EmbeddingsIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EmbeddingsIcon } from './EmbeddingsIcons';
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function GraphiteIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="22"
|
||||
height="25"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 22 25"
|
||||
aria-label="Graphite"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.39873 13.0137C12.2825 13.0137 14.6203 10.7138 14.6203 7.87671C14.6203 5.03963 12.2825 2.73973 9.39873 2.73973C6.51497 2.73973 4.17722 5.03963 4.17722 7.87671C4.17722 10.7138 6.51497 13.0137 9.39873 13.0137ZM9.39873 15.7534C13.8205 15.7534 17.4051 12.2269 17.4051 7.87671C17.4051 3.52652 13.8205 0 9.39873 0C4.97696 0 1.39241 3.52652 1.39241 7.87671C1.39241 12.2269 4.97696 15.7534 9.39873 15.7534Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.78481 15.7534C2.78481 19.3471 5.74597 22.2603 9.39873 22.2603C13.0515 22.2603 16.0127 19.3471 16.0127 15.7534H18.7975C18.7975 20.8602 14.5895 25 9.39873 25C4.20796 25 0 20.8602 0 15.7534H2.78481Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.37975 1.36986C7.37975 0.613309 8.00315 0 8.77215 0H20.6076C21.3766 0 22 0.613309 22 1.36986C22 2.12642 21.3766 2.73973 20.6076 2.73973H8.77215C8.00315 2.73973 7.37975 2.12642 7.37975 1.36986Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
GraphiteIcon.displayName = 'NhostGraphiteIcon';
|
||||
|
||||
export default GraphiteIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GraphiteIcon } from './GraphiteIcon';
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
345
dashboard/src/features/ai/AssistantForm/AssistantForm.tsx
Normal file
345
dashboard/src/features/ai/AssistantForm/AssistantForm.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
||||
import { WebhooksDataSourcesFormSection } from '@/features/ai/AssistantForm/components/WebhooksDataSourcesFormSection';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
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';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
description: Yup.string(),
|
||||
instructions: Yup.string().required('The instructions are required'),
|
||||
model: Yup.string().required('The model is required'),
|
||||
graphql: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
query: Yup.string().required(),
|
||||
arguments: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
type: Yup.string().required(),
|
||||
required: Yup.bool().required(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
webhooks: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
URL: Yup.string().required(),
|
||||
arguments: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
type: Yup.string().required(),
|
||||
required: Yup.bool().required(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface AssistantFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
||||
*/
|
||||
assistantId?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AssistantFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the submit is successful.
|
||||
*/
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
}
|
||||
|
||||
export default function AssistantForm({
|
||||
assistantId,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: AssistantFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [insertAssistantMutation] = useInsertAssistantMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const [updateAssistantMutation] = useUpdateAssistantMutation({ client });
|
||||
|
||||
const form = useForm<AssistantFormValues>({
|
||||
defaultValues: initialData,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
) => {
|
||||
// remove any __typename from the form values
|
||||
const payload = removeTypename(values);
|
||||
|
||||
if (values.webhooks.length === 0) {
|
||||
delete payload.webhooks;
|
||||
}
|
||||
|
||||
if (values.graphql.length === 0) {
|
||||
delete payload.graphql;
|
||||
}
|
||||
|
||||
// remove assistantId because the update mutation fails otherwise
|
||||
delete payload.assistantID;
|
||||
|
||||
// If the assistantId is set then we do an update
|
||||
if (assistantId) {
|
||||
await updateAssistantMutation({
|
||||
variables: {
|
||||
id: assistantId,
|
||||
data: payload,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await insertAssistantMutation({
|
||||
variables: {
|
||||
data: {
|
||||
...values,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
) => {
|
||||
try {
|
||||
await toast.promise(
|
||||
createOrUpdateAutoEmbeddings(values),
|
||||
{
|
||||
loading: 'Configuring the Assistant...',
|
||||
success: `The Assistant has been configured 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 configuring the Assistant. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden border-t"
|
||||
>
|
||||
<div className="flex flex-1 flex-col space-y-4 overflow-auto p-4">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Name of the assistant">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('description')}
|
||||
id="description"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Description</Text>
|
||||
<Tooltip title={<span>Description of the assistant</span>}>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.description}
|
||||
helperText={errors?.description?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('instructions')}
|
||||
id="instructions"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Instructions</Text>
|
||||
<Tooltip title="Instructions for the assistant. This is used to instruct the AI assistant on how to behave and respond to the user">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.instructions}
|
||||
helperText={errors?.instructions?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('model')}
|
||||
id="model"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Model</Text>
|
||||
<Tooltip title="Model to use for the assistant.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.model}
|
||||
helperText={errors?.model?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<GraphqlDataSourcesFormSection />
|
||||
<WebhooksDataSourcesFormSection />
|
||||
</div>
|
||||
|
||||
<Box className="flex w-full flex-row justify-between rounded border-t p-4">
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={assistantId ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{assistantId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { ControlledSwitch } from '@/components/form/ControlledSwitch';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { type AssistantFormValues } from '@/features/ai/AssistantForm/AssistantForm';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
interface ArgumentsFormSectionProps {
|
||||
nestedField: string;
|
||||
nestIndex: number;
|
||||
}
|
||||
|
||||
export default function ArgumentsFormSection({
|
||||
nestedField,
|
||||
nestIndex,
|
||||
}: ArgumentsFormSectionProps) {
|
||||
const form = useFormContext<AssistantFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: `${nestedField}.${nestIndex}.arguments`,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4">
|
||||
<div className="flex flex-row items-center justify-between ">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Arguments
|
||||
</Text>
|
||||
<Tooltip title={<span>Arguments</span>}>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button variant="borderless" onClick={() => append({})}>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box
|
||||
key={field.id}
|
||||
className="flex flex-col space-y-20 rounded border-1 p-4"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
<Input
|
||||
// We're putting ts-ignore here so we could use the same components for both graphql and webhooks
|
||||
// by passing the nestedField = 'graphql' or nestedField = 'webhooks'
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.name`,
|
||||
)}
|
||||
id={`${field.id}-name`}
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={
|
||||
!!errors?.[nestedField]?.[nestIndex]?.arguments[index].name
|
||||
}
|
||||
helperText={
|
||||
errors?.[nestedField]?.[nestIndex]?.arguments[index]?.name
|
||||
?.message
|
||||
}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.description`,
|
||||
)}
|
||||
id={`${field.id}-description`}
|
||||
placeholder="Description"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={
|
||||
!!errors?.[nestedField]?.[nestIndex]?.arguments[index]
|
||||
.description
|
||||
}
|
||||
helperText={
|
||||
errors?.[nestedField]?.[nestIndex]?.arguments[index]
|
||||
?.description?.message
|
||||
}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Box className="w-full">
|
||||
<ControlledSelect
|
||||
fullWidth
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.type`,
|
||||
)}
|
||||
id={`${field.id}-type`}
|
||||
placeholder="Select argument type"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{[
|
||||
'string',
|
||||
'number',
|
||||
'integer',
|
||||
'object',
|
||||
'array',
|
||||
'boolean',
|
||||
]?.map((argumentType) => (
|
||||
<Option key={argumentType} value={argumentType}>
|
||||
{argumentType}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledSelect>
|
||||
</Box>
|
||||
<ControlledSwitch
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.required`,
|
||||
)}
|
||||
disabled={false}
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Required
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="h-10 self-end"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ArgumentsFormSection } from './ArgumentsFormSection';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { type AssistantFormValues } from '@/features/ai/AssistantForm/AssistantForm';
|
||||
import { ArgumentsFormSection } from '@/features/ai/AssistantForm/components/ArgumentsFormSection';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function GraphqlDataSourcesFormSection() {
|
||||
const form = useFormContext<AssistantFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'graphql',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between p-4 pb-0">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
GraphQL
|
||||
</Text>
|
||||
<Tooltip title="GraphQL data sources and tools. Run against the project's GraphQL API">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() =>
|
||||
append({
|
||||
name: '',
|
||||
description: '',
|
||||
query: '',
|
||||
arguments: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box key={field.id} className="flex flex-col space-y-4">
|
||||
<Box className="flex w-full flex-col space-y-4 p-4 pt-0">
|
||||
<Input
|
||||
{...register(`graphql.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.graphql?.at(index)?.name}
|
||||
helperText={errors?.graphql?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`graphql.${index}.description`)}
|
||||
id={`${field.id}-description`}
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.graphql?.at(index)?.description}
|
||||
helperText={errors?.graphql?.at(index)?.description?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`graphql.${index}.query`)}
|
||||
id={`${field.id}-query`}
|
||||
label="Query"
|
||||
placeholder="Query"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.graphql?.at(index)?.query}
|
||||
helperText={errors?.graphql?.at(index)?.query?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ArgumentsFormSection nestedField="graphql" nestIndex={index} />
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="h-10 self-end"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{index < fields.length - 1 && (
|
||||
<Divider className="h-px" sx={{ background: 'grey.200' }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GraphqlDataSourcesFormSection } from './GraphqlDataSourcesFormSection';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { type AssistantFormValues } from '@/features/ai/AssistantForm/AssistantForm';
|
||||
import { ArgumentsFormSection } from '@/features/ai/AssistantForm/components/ArgumentsFormSection';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function WebhooksDataSourcesFormSection() {
|
||||
const form = useFormContext<AssistantFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'webhooks',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between p-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Webhooks
|
||||
</Text>
|
||||
<Tooltip title="Webhook data sources and tools">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() =>
|
||||
append({
|
||||
name: '',
|
||||
description: '',
|
||||
URL: '',
|
||||
arguments: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box key={field.id} className="flex flex-col space-y-4">
|
||||
<Box className="flex w-full flex-col space-y-4 p-4 pt-0">
|
||||
<Input
|
||||
{...register(`webhooks.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.webhooks?.at(index)?.name}
|
||||
helperText={errors?.webhooks?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`webhooks.${index}.description`)}
|
||||
id={`${field.id}-description`}
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.webhooks?.at(index)?.description}
|
||||
helperText={errors?.webhooks?.at(index)?.description?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`webhooks.${index}.URL`)}
|
||||
id={`${field.id}-URL`}
|
||||
label="URL"
|
||||
placeholder="URL"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.webhooks?.at(index)?.URL}
|
||||
helperText={errors?.webhooks?.at(index)?.URL?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ArgumentsFormSection nestedField="webhooks" nestIndex={index} />
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="h-10 self-end"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{index < fields.length - 1 && (
|
||||
<Divider className="h-px" sx={{ background: 'grey.200' }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WebhooksDataSourcesFormSection } from './WebhooksDataSourcesFormSection';
|
||||
1
dashboard/src/features/ai/AssistantForm/index.ts
Normal file
1
dashboard/src/features/ai/AssistantForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AssistantForm } from './AssistantForm';
|
||||
158
dashboard/src/features/ai/AssistantsList/AssistantsList.tsx
Normal file
158
dashboard/src/features/ai/AssistantsList/AssistantsList.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AssistantForm } from '@/features/ai/AssistantForm';
|
||||
import { DeleteAssistantModal } from '@/features/ai/DeleteAssistantModal';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
||||
|
||||
interface AssistantsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
*/
|
||||
assistants: Assistant[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function AssistantsList({
|
||||
assistants,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: AssistantsListProps) {
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewAssistant = async (assistant: Assistant) => {
|
||||
openDrawer({
|
||||
title: `Edit ${assistant?.name ?? 'unset'}`,
|
||||
component: (
|
||||
<AssistantForm
|
||||
assistantId={assistant.assistantID}
|
||||
initialData={{
|
||||
...assistant,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAssistant = async (assistant: Assistant) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteAssistantModal
|
||||
assistant={assistant}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{assistants.map((assistant) => (
|
||||
<Box
|
||||
key={assistant.assistantID}
|
||||
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||
sx={{
|
||||
[`&:hover`]: {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => viewAssistant(assistant)}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<span className="text-3xl">🤖</span>
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{assistant?.name ?? 'unset'}
|
||||
</Text>
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{assistant.assistantID}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
copy(assistant.assistantID, 'Assistant Id');
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
aria-label="More options"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-auto' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => viewAssistant(assistant)}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Text className="font-medium">View {assistant?.name}</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteAssistant(assistant)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete {assistant?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/AssistantsList/index.ts
Normal file
1
dashboard/src/features/ai/AssistantsList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AssistantsList } from './AssistantsList';
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import {
|
||||
useInsertGraphiteAutoEmbeddingsConfigurationMutation,
|
||||
useUpdateGraphiteAutoEmbeddingsConfigurationMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
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';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
schemaName: Yup.string().required('The schema is required'),
|
||||
tableName: Yup.string().required('The table is required'),
|
||||
columnName: Yup.string().required('The column is required'),
|
||||
query: Yup.string(),
|
||||
mutation: Yup.string(),
|
||||
});
|
||||
|
||||
export type AutoEmbeddingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface AutoEmbeddingsFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
||||
*/
|
||||
autoEmbeddingsId?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AutoEmbeddingsFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the submit is successful.
|
||||
*/
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
}
|
||||
|
||||
export default function AutoEmbeddingsForm({
|
||||
autoEmbeddingsId,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: AutoEmbeddingsFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [insertGraphiteAutoEmbeddingsConfiguration] =
|
||||
useInsertGraphiteAutoEmbeddingsConfigurationMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const [updateGraphiteAutoEmbeddingsConfiguration] =
|
||||
useUpdateGraphiteAutoEmbeddingsConfigurationMutation({ client });
|
||||
|
||||
const form = useForm<AutoEmbeddingsFormValues>({
|
||||
defaultValues: initialData,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: AutoEmbeddingsFormValues,
|
||||
) => {
|
||||
// If the autoEmbeddingsId is set then we do an update
|
||||
if (autoEmbeddingsId) {
|
||||
await updateGraphiteAutoEmbeddingsConfiguration({
|
||||
variables: {
|
||||
id: autoEmbeddingsId,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await insertGraphiteAutoEmbeddingsConfiguration({
|
||||
variables: values,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: AutoEmbeddingsFormValues) => {
|
||||
try {
|
||||
await toast.promise(
|
||||
createOrUpdateAutoEmbeddings(values),
|
||||
{
|
||||
loading: 'Configuring the Auto-Embeddings...',
|
||||
success: `The Auto-Embeddings has been configured 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 configuring the Auto-Embeddings. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col gap-4 overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-1 flex-col space-y-6 overflow-auto px-6">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Name of the Auto-Embeddings">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
{...register('schemaName')}
|
||||
id="schemaName"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Schema</Text>
|
||||
<Tooltip title={<span>Schema where the table belongs to</span>}>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.schemaName}
|
||||
helperText={errors?.schemaName?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register('tableName')}
|
||||
id="tableName"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Table</Text>
|
||||
<Tooltip title="Table Name">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.tableName}
|
||||
helperText={errors?.tableName?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register('columnName')}
|
||||
id="columnName"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Column</Text>
|
||||
<Tooltip title="Column name">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.columnName}
|
||||
helperText={errors?.columnName?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register('query')}
|
||||
id="query"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Query</Text>
|
||||
<Tooltip title="Query">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.query}
|
||||
helperText={errors?.query?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
rows={6}
|
||||
/>
|
||||
<Input
|
||||
{...register('mutation')}
|
||||
id="mutation"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Mutation</Text>
|
||||
<Tooltip title="Mutation">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.mutation}
|
||||
helperText={errors?.mutation?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box className="flex w-full flex-row justify-between rounded border-t px-6 py-4">
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={autoEmbeddingsId ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{autoEmbeddingsId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/AutoEmbeddingsForm/index.ts
Normal file
1
dashboard/src/features/ai/AutoEmbeddingsForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AutoEmbeddingsForm } from './AutoEmbeddingsForm';
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CubeIcon } from '@/components/ui/v2/icons/CubeIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { EmbeddingsIcon } from '@/components/ui/v2/icons/EmbeddingsIcon';
|
||||
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 { AutoEmbeddingsForm } from '@/features/ai/AutoEmbeddingsForm';
|
||||
import { DeleteAutoEmbeddingsModal } from '@/features/ai/DeleteAutoEmbeddingsModal';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { AutoEmbeddingsConfiguration } from 'pages/[workspaceSlug]/[appSlug]/ai/auto-embeddings';
|
||||
|
||||
interface AutoEmbeddingsConfigurationsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
*/
|
||||
autoEmbeddingsConfigurations: AutoEmbeddingsConfiguration[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function AutoEmbeddingsList({
|
||||
autoEmbeddingsConfigurations,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: AutoEmbeddingsConfigurationsListProps) {
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewAutoEmbeddingsConfiguration = async (
|
||||
autoEmbeddingsConfiguration: AutoEmbeddingsConfiguration,
|
||||
) => {
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Edit {autoEmbeddingsConfiguration?.name ?? 'unset'}</Text>
|
||||
</Box>
|
||||
),
|
||||
component: (
|
||||
<AutoEmbeddingsForm
|
||||
autoEmbeddingsId={autoEmbeddingsConfiguration.id}
|
||||
initialData={{
|
||||
...autoEmbeddingsConfiguration,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAutoEmbeddingsConfiguration = async (
|
||||
autoEmbeddingsConfiguration: AutoEmbeddingsConfiguration,
|
||||
) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteAutoEmbeddingsModal
|
||||
autoEmbeddingsConfiguration={autoEmbeddingsConfiguration}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{autoEmbeddingsConfigurations.map((autoEmbeddingsConfiguration) => (
|
||||
<Box
|
||||
key={autoEmbeddingsConfiguration.id}
|
||||
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||
sx={{
|
||||
[`&:hover`]: {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() =>
|
||||
viewAutoEmbeddingsConfiguration(autoEmbeddingsConfiguration)
|
||||
}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<EmbeddingsIcon className="h-5 w-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{autoEmbeddingsConfiguration?.name ?? 'unset'}
|
||||
</Text>
|
||||
<Tooltip title={autoEmbeddingsConfiguration.updatedAt}>
|
||||
<span className="hidden cursor-pointer text-sm text-slate-500 xs+:flex">
|
||||
Updated{' '}
|
||||
{formatDistanceToNow(
|
||||
new Date(autoEmbeddingsConfiguration.updatedAt),
|
||||
)}{' '}
|
||||
ago
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
aria-label="More options"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-auto' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
viewAutoEmbeddingsConfiguration(autoEmbeddingsConfiguration)
|
||||
}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Text className="font-medium">
|
||||
View {autoEmbeddingsConfiguration?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
deleteAutoEmbeddingsConfiguration(autoEmbeddingsConfiguration)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete {autoEmbeddingsConfiguration?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/AutoEmbeddingsList/index.ts
Normal file
1
dashboard/src/features/ai/AutoEmbeddingsList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AutoEmbeddingsList } from './AutoEmbeddingsList';
|
||||
@@ -0,0 +1,143 @@
|
||||
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 { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { useDeleteAssistantMutation } from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteAssistantModalProps {
|
||||
assistant: Assistant;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteAssistantModal({
|
||||
assistant,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteAssistantModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [deleteAssistantMutation] = useDeleteAssistantMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const deleteAssistant = async () => {
|
||||
await deleteAssistantMutation({
|
||||
variables: {
|
||||
id: assistant.assistantID,
|
||||
},
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteAssistant(),
|
||||
{
|
||||
loading: 'Deleting the assistant...',
|
||||
success: `The Assistant 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 Assistant. 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 Assistant {assistant?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this Assistant?
|
||||
</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 ${assistant?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Assistant"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Assistant
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/DeleteAssistantModal/index.ts
Normal file
1
dashboard/src/features/ai/DeleteAssistantModal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DeleteAssistantModal } from './DeleteAssistantModal';
|
||||
@@ -0,0 +1,145 @@
|
||||
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 { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { useDeleteGraphiteAutoEmbeddingsConfigurationMutation } from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { type AutoEmbeddingsConfiguration } from 'pages/[workspaceSlug]/[appSlug]/ai/auto-embeddings';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteAutoEmbeddingsModalProps {
|
||||
autoEmbeddingsConfiguration: AutoEmbeddingsConfiguration;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteAutoEmbeddingsModal({
|
||||
autoEmbeddingsConfiguration,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteAutoEmbeddingsModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [deleteAutoEmbeddingsConfiguration] =
|
||||
useDeleteGraphiteAutoEmbeddingsConfigurationMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const deleteAutoEmbeddingsConfig = async () => {
|
||||
await deleteAutoEmbeddingsConfiguration({
|
||||
variables: {
|
||||
id: autoEmbeddingsConfiguration.id,
|
||||
},
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteAutoEmbeddingsConfig(),
|
||||
{
|
||||
loading: 'Deleting Auto-Embeddings Configuration...',
|
||||
success: `The Auto-Embeddings Configuration 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 Auto-Embeddings Configuration. 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 Auto-Embeddings Configuration{' '}
|
||||
{autoEmbeddingsConfiguration?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this Auto-Embeddings Configuration?
|
||||
</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 ${autoEmbeddingsConfiguration?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Auto-Embeddings Configuration"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Auto-Embeddings Configuration
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteAutoEmbeddingsModal } from './DeleteAutoEmbeddingsModal';
|
||||
219
dashboard/src/features/ai/DevAssistant/DevAssistant.tsx
Normal file
219
dashboard/src/features/ai/DevAssistant/DevAssistant.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { UpgradeToProBanner } from '@/components/common/UpgradeToProBanner';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ArrowUpIcon } from '@/components/ui/v2/icons/ArrowUpIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { MessagesList } from '@/features/ai/DevAssistant/components/MessagesList';
|
||||
import {
|
||||
messagesState,
|
||||
projectMessagesState,
|
||||
sessionIDState,
|
||||
} from '@/features/ai/DevAssistant/state';
|
||||
import { useAdminApolloClient } from '@/features/projects/common/hooks/useAdminApolloClient';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useSendDevMessageMutation,
|
||||
useStartDevSessionMutation,
|
||||
type SendDevMessageMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
const MAX_THREAD_LENGTH = 50;
|
||||
|
||||
export type Message = Omit<
|
||||
SendDevMessageMutation['graphite']['sendDevMessage']['messages'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function DevAssistant() {
|
||||
const { currentProject, currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userInput, setUserInput] = useState('');
|
||||
const setMessages = useSetRecoilState(messagesState);
|
||||
const messages = useRecoilValue(projectMessagesState(currentProject.id));
|
||||
const [storedSessionID, setStoredSessionID] = useRecoilState(sessionIDState);
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
const [startDevSession] = useStartDevSessionMutation({ client: adminClient });
|
||||
const [sendDevMessage] = useSendDevMessageMutation({ client: adminClient });
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setUserInput('');
|
||||
|
||||
let sessionID = storedSessionID;
|
||||
const lastMessage = messages.slice(1).pop(); // The first message is a welcome message, so we exclude it
|
||||
|
||||
let hasBeenAnHourSinceLastMessage = false;
|
||||
if (lastMessage) {
|
||||
hasBeenAnHourSinceLastMessage =
|
||||
new Date().getTime() - new Date(lastMessage.createdAt).getTime() >
|
||||
60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const $messages = [
|
||||
...messages,
|
||||
{
|
||||
id: String(new Date().getTime()),
|
||||
message: userInput,
|
||||
createdAt: null,
|
||||
role: 'user',
|
||||
projectId: currentProject.id,
|
||||
},
|
||||
];
|
||||
|
||||
setMessages($messages);
|
||||
|
||||
if (!sessionID || hasBeenAnHourSinceLastMessage) {
|
||||
const sessionRes = await startDevSession({ client: adminClient });
|
||||
sessionID = sessionRes?.data?.graphite?.startDevSession?.sessionID;
|
||||
setStoredSessionID(sessionID);
|
||||
}
|
||||
|
||||
if (!sessionID) {
|
||||
throw new Error('Failed to start a new session');
|
||||
}
|
||||
|
||||
const {
|
||||
data: {
|
||||
graphite: { sendDevMessage: { messages: newMessages } = {} } = {},
|
||||
} = {},
|
||||
} = await sendDevMessage({
|
||||
variables: {
|
||||
message: userInput,
|
||||
sessionId: sessionID || '',
|
||||
prevMessageID: !hasBeenAnHourSinceLastMessage
|
||||
? lastMessage?.id || ''
|
||||
: '',
|
||||
},
|
||||
});
|
||||
|
||||
let thread = [
|
||||
// remove the temp messages of the user input while we wait for the dev assistant to respond
|
||||
...$messages.filter((item) => item.createdAt),
|
||||
...newMessages
|
||||
|
||||
// remove empty messages
|
||||
.filter((item) => item.message)
|
||||
|
||||
// add the currentProject.id to the new messages
|
||||
.map((item) => ({ ...item, projectId: currentProject.id })),
|
||||
];
|
||||
|
||||
if (thread.length > MAX_THREAD_LENGTH) {
|
||||
thread = thread.slice(thread.length - MAX_THREAD_LENGTH); // keep the thread at a max length of MAX_THREAD_LENGTH
|
||||
}
|
||||
|
||||
setMessages(thread);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
'Failed to send the message to graphite. Please try again later.',
|
||||
{
|
||||
style: getToastStyleProps().style,
|
||||
...getToastStyleProps().error,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget.closest('form');
|
||||
if (form) {
|
||||
form.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentProject.plan.isFree) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<UpgradeToProBanner
|
||||
title="Upgrade to Nhost Pro."
|
||||
description={
|
||||
<Text>
|
||||
Graphite is an addon to the Pro plan. To unlock it, please upgrade
|
||||
to Pro first.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentProject.plan.isFree && !currentProject.config?.ai) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert className="grid w-full grid-flow-col place-content-between items-center gap-2">
|
||||
<Text className="grid grid-flow-row justify-items-start gap-0.5">
|
||||
<Text component="span">
|
||||
To enable graphite, configure the service first in{' '}
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentProject.slug}/settings/ai`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
AI Settings
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Text>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<MessagesList loading={loading} />
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Box className="relative flex w-full flex-row justify-between p-2">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(event) => {
|
||||
const { value } = event.target;
|
||||
setUserInput(value);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask graphite anything!"
|
||||
className="w-full"
|
||||
required
|
||||
slotProps={{
|
||||
input: { className: 'w-full rounded-none border-none' },
|
||||
}}
|
||||
multiline
|
||||
maxRows={7}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
disabled={!userInput || loading}
|
||||
color="primary"
|
||||
aria-label="Send"
|
||||
type="submit"
|
||||
className="absolute right-2 h-10 w-12 self-end rounded-xl"
|
||||
>
|
||||
{loading ? <ActivityIndicator /> : <ArrowUpIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
export default function LoadingAssistantMessage() {
|
||||
return (
|
||||
<Box className="flex flex-col space-y-4 border-t p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<GraphiteIcon />
|
||||
<Text className="font-bold">Assistant</Text>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<Box
|
||||
className="h-1.5 w-1.5 animate-blinking rounded-full"
|
||||
sx={{ backgroundColor: 'grey.600' }}
|
||||
/>
|
||||
<Box
|
||||
className="h-1.5 w-1.5 animate-blinking rounded-full animate-delay-150"
|
||||
sx={{ backgroundColor: 'grey.600' }}
|
||||
/>
|
||||
<Box
|
||||
className="h-1.5 w-1.5 animate-blinking rounded-full animate-delay-300"
|
||||
sx={{ backgroundColor: 'grey.600' }}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LoadingAssistantMessage } from './LoadingAssistantMessage';
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { type Message } from '@/features/ai/DevAssistant';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { onlyText } from 'react-children-utilities';
|
||||
import Markdown, { type ExtraProps } from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkGFM from 'remark-gfm';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { type ClassAttributes, type HTMLAttributes } from 'react';
|
||||
|
||||
function PreComponent(
|
||||
props: ClassAttributes<HTMLElement> &
|
||||
HTMLAttributes<HTMLElement> &
|
||||
ExtraProps,
|
||||
) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<pre>{children}</pre>
|
||||
<IconButton
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
padding: 0.5,
|
||||
backgroundColor: 'grey.100',
|
||||
}}
|
||||
color="warning"
|
||||
variant="contained"
|
||||
className="absolute top-2 right-2 hidden group-hover:flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(onlyText(children), 'Snippet');
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageBox({ message }: { message: Message }) {
|
||||
const theme = useTheme();
|
||||
const user = useUserData();
|
||||
const isUserMessage = message.role === 'user';
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex flex-col space-y-4 border-t p-4 first:border-t-0"
|
||||
sx={{
|
||||
backgroundColor: isUserMessage && 'background.default',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{message.role === 'assistant' ? (
|
||||
<>
|
||||
<GraphiteIcon />
|
||||
<Text className="font-bold">Assistant</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar
|
||||
className="h-7 w-7 rounded-full"
|
||||
alt={user?.displayName}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
{user?.displayName || 'local'}
|
||||
</Avatar>
|
||||
<Text className="font-bold">
|
||||
{user?.displayName || 'local'} (You)
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Markdown
|
||||
className={twMerge(
|
||||
'prose',
|
||||
theme.palette.mode === 'dark' && 'prose-invert',
|
||||
)}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
remarkPlugins={[remarkGFM]}
|
||||
components={{
|
||||
pre: PreComponent,
|
||||
}}
|
||||
>
|
||||
{message.message}
|
||||
</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MessageBox } from './MessageBox';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { LoadingAssistantMessage } from '@/features/ai/DevAssistant/components/LoadingAssistantMessage';
|
||||
import { MessageBox } from '@/features/ai/DevAssistant/components/MessageBox';
|
||||
import { projectMessagesState } from '@/features/ai/DevAssistant/state';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
interface MessagesListProps {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MessagesList({ loading }: MessagesListProps) {
|
||||
const bottomElement = useRef(null);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const messages = useRecoilValue(projectMessagesState(currentProject.id));
|
||||
|
||||
const scrollToBottom = () =>
|
||||
bottomElement?.current?.scrollIntoView({ behavior: 'instant' });
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading]);
|
||||
|
||||
return (
|
||||
<Box className="flex grow flex-col overflow-auto border-y">
|
||||
{messages.map((message) => (
|
||||
<MessageBox key={message.id} message={message} />
|
||||
))}
|
||||
{loading && <LoadingAssistantMessage />}
|
||||
<div ref={bottomElement} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessagesList);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MessagesList } from './MessagesList';
|
||||
2
dashboard/src/features/ai/DevAssistant/index.ts
Normal file
2
dashboard/src/features/ai/DevAssistant/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './DevAssistant';
|
||||
export { default as DevAssistant } from './DevAssistant';
|
||||
5
dashboard/src/features/ai/DevAssistant/state/index.ts
Normal file
5
dashboard/src/features/ai/DevAssistant/state/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './messages';
|
||||
export { default as messagesState } from './messages';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
export { default as projectMessagesState } from './projectMessages';
|
||||
export { default as sessionIDState } from './session';
|
||||
23
dashboard/src/features/ai/DevAssistant/state/messages.ts
Normal file
23
dashboard/src/features/ai/DevAssistant/state/messages.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type Message } from '@/features/ai/DevAssistant';
|
||||
import { persistAtom } from '@/utils/recoil';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export interface ProjectMessage extends Message {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const messagesState = atom<ProjectMessage[]>({
|
||||
key: 'messages',
|
||||
default: [
|
||||
{
|
||||
id: '0',
|
||||
message:
|
||||
"Hi, I'm your personal Nhost AI assistant. I'm here to help answer questions, assist with tasks, provide information, or just have a conversation about GraphQL!",
|
||||
role: 'assistant',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
effects: [persistAtom],
|
||||
});
|
||||
|
||||
export default messagesState;
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
messagesState,
|
||||
type ProjectMessage,
|
||||
} from '@/features/ai/DevAssistant/state';
|
||||
import { selectorFamily } from 'recoil';
|
||||
|
||||
const projectMessagesState = selectorFamily<ProjectMessage[], string>({
|
||||
key: 'projectMessages',
|
||||
get:
|
||||
(projectId) =>
|
||||
({ get }) => {
|
||||
const messages = get(messagesState);
|
||||
|
||||
return messages.filter(
|
||||
(message) =>
|
||||
message.projectId === projectId || message.projectId === undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default projectMessagesState;
|
||||
10
dashboard/src/features/ai/DevAssistant/state/session.ts
Normal file
10
dashboard/src/features/ai/DevAssistant/state/session.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { persistAtom } from '@/utils/recoil';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
const sessionIDState = atom<string>({
|
||||
key: 'sessionID',
|
||||
default: '',
|
||||
effects: [persistAtom],
|
||||
});
|
||||
|
||||
export default sessionIDState;
|
||||
423
dashboard/src/features/ai/settings/components/AISettings.tsx
Normal file
423
dashboard/src/features/ai/settings/components/AISettings.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
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 { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { ComputeFormSection } from '@/features/services/components/ServiceForm/components/ComputeFormSection';
|
||||
import {
|
||||
Software_Type_Enum,
|
||||
useGetAiSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
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';
|
||||
import { DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
}),
|
||||
webhookSecret: Yup.string(),
|
||||
synchPeriodMinutes: Yup.number(),
|
||||
organization: Yup.string(),
|
||||
apiKey: Yup.string().required(),
|
||||
compute: Yup.object({
|
||||
cpu: Yup.number().required(),
|
||||
memory: Yup.number().required(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AISettingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AISettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { openDialog } = useDialog();
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [aiServiceEnabled, setAIServiceEnabled] = useState(true);
|
||||
|
||||
const {
|
||||
data: { config: { ai } = {} } = {},
|
||||
loading: loadingAiSettings,
|
||||
error: errorGettingAiSettings,
|
||||
} = useGetAiSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: graphiteVersionsData, loading: loadingGraphiteVersionsData } =
|
||||
useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Graphite,
|
||||
},
|
||||
});
|
||||
|
||||
const graphiteVersions = graphiteVersionsData?.softwareVersions || [];
|
||||
|
||||
const availableVersionsSet = new Set(
|
||||
graphiteVersions.map((el) => el.version),
|
||||
);
|
||||
|
||||
if (ai?.version) {
|
||||
availableVersionsSet.add(ai.version);
|
||||
}
|
||||
|
||||
const availableVersions = Array.from(availableVersionsSet)
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((availableVersion) => ({
|
||||
label: availableVersion,
|
||||
value: availableVersion,
|
||||
}));
|
||||
|
||||
const form = useForm<AISettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
version: {
|
||||
label: '0.1.0-beta4',
|
||||
value: '0.1.0-beta4',
|
||||
},
|
||||
webhookSecret: '',
|
||||
organization: '',
|
||||
apiKey: '',
|
||||
synchPeriodMinutes: 5,
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, reset, watch } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (ai) {
|
||||
reset({
|
||||
version: { label: ai?.version, value: ai?.version },
|
||||
webhookSecret: ai?.webhookSecret,
|
||||
synchPeriodMinutes: ai?.autoEmbeddings?.synchPeriodMinutes,
|
||||
apiKey: ai?.openai?.apiKey,
|
||||
organization: ai?.openai?.organization,
|
||||
compute: {
|
||||
cpu: ai?.resources?.compute?.cpu ?? 62,
|
||||
memory: ai?.resources?.compute?.memory ?? 128,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setAIServiceEnabled(!!ai);
|
||||
}, [ai, reset]);
|
||||
|
||||
const toggleAIService = async (enabled: boolean) => {
|
||||
setAIServiceEnabled(enabled);
|
||||
|
||||
if (!enabled) {
|
||||
openDialog({
|
||||
title: 'Confirm Disabling the AI service',
|
||||
component: (
|
||||
<DisableAIServiceConfirmationDialog
|
||||
onCancel={() => setAIServiceEnabled(true)}
|
||||
onServiceDisabled={() => setAIServiceEnabled(false)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingAiSettings || loadingGraphiteVersionsData) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Postgres version..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorGettingAiSettings) {
|
||||
throw errorGettingAiSettings;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: AISettingsFormValues) {
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
ai: {
|
||||
version: formValues.version.value,
|
||||
webhookSecret: formValues.webhookSecret,
|
||||
autoEmbeddings: {
|
||||
synchPeriodMinutes: Number(formValues.synchPeriodMinutes),
|
||||
},
|
||||
openai: {
|
||||
apiKey: formValues.apiKey,
|
||||
organization: formValues.organization,
|
||||
},
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: formValues?.compute?.cpu,
|
||||
memory: formValues?.compute?.memory,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `AI settings are being updated...`,
|
||||
success: `AI settings has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the AI settings!`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
const aiSettingsFormValues = watch();
|
||||
|
||||
const getAIResourcesCost = () => {
|
||||
const vCPUs = `${
|
||||
aiSettingsFormValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER
|
||||
} vCPUs`;
|
||||
const mem = `${aiSettingsFormValues.compute.memory} MiB Mem`;
|
||||
const details = `${vCPUs} + ${mem}`;
|
||||
|
||||
return `Approximate cost for ${details}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4" sx={{ backgroundColor: 'background.default' }}>
|
||||
<Box className="flex flex-row items-center justify-between rounded-lg border-1 p-4">
|
||||
<Text className="text-lg font-semibold">Enable AI service</Text>
|
||||
<Switch
|
||||
checked={aiServiceEnabled}
|
||||
onChange={(e) => toggleAIService(e.target.checked)}
|
||||
className="self-center"
|
||||
/>
|
||||
</Box>
|
||||
{aiServiceEnabled && (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title={null}
|
||||
description={null}
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<Box className="space-y-4">
|
||||
<Box className="space-y-2">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text className="text-lg font-semibold">Version</Text>
|
||||
<Tooltip title="Version of the service to use.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
filterOptions={(options, state) => {
|
||||
if (state.inputValue === ai?.version) {
|
||||
return options;
|
||||
}
|
||||
return filterOptions(options, state);
|
||||
}}
|
||||
fullWidth
|
||||
className="col-span-4"
|
||||
options={availableVersions}
|
||||
error={!!formState.errors?.version?.message}
|
||||
helperText={formState.errors?.version?.message}
|
||||
showCustomOption="auto"
|
||||
customOptionLabel={(value) =>
|
||||
`Use custom value: "${value}"`
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text className="text-lg font-semibold">
|
||||
Webhook Secret
|
||||
</Text>
|
||||
<Tooltip title="Used to validate requests between postgres and the AI service. The AI service will also include the header X-Graphite-Webhook-Secret with this value set when calling external webhooks so the source of the request can be validated.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Input
|
||||
{...register('webhookSecret')}
|
||||
id="webhookSecret"
|
||||
name="webhookSecret"
|
||||
placeholder="Webhook Secret"
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={Boolean(formState.errors.webhookSecret?.message)}
|
||||
helperText={formState.errors.webhookSecret?.message}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text className="text-lg font-semibold">Resources</Text>
|
||||
<Tooltip title="Dedicated resources allocated for the service.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
className="flex items-center justify-between space-x-2"
|
||||
>
|
||||
<span>{getAIResourcesCost()}</span>
|
||||
<b>
|
||||
$
|
||||
{parseFloat(
|
||||
(
|
||||
aiSettingsFormValues.compute.cpu * COST_PER_VCPU
|
||||
).toFixed(2),
|
||||
)}
|
||||
</b>
|
||||
</Alert>
|
||||
|
||||
<ComputeFormSection />
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Text className="text-lg font-semibold">OpenAI</Text>
|
||||
|
||||
<Input
|
||||
{...register('apiKey')}
|
||||
name="apiKey"
|
||||
placeholder="API Key"
|
||||
id="apiKey"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>OpenAI API key</Text>
|
||||
<Tooltip title="Key to use for authenticating API requests to OpenAI">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={Boolean(formState.errors.apiKey?.message)}
|
||||
helperText={formState.errors.apiKey?.message}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('organization')}
|
||||
id="organization"
|
||||
name="organization"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>OpenAI Organization</Text>
|
||||
<Tooltip title="Optional. OpenAI organization to use.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="Organization"
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={Boolean(formState.errors.organization?.message)}
|
||||
helperText={formState.errors.organization?.message}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Text className="text-lg font-semibold">Auto-Embeddings</Text>
|
||||
<Input
|
||||
{...register('synchPeriodMinutes')}
|
||||
id="synchPeriodMinutes"
|
||||
name="synchPeriodMinutes"
|
||||
type="number"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Synch Period Minutes</Text>
|
||||
<Tooltip title="How often to run the job that keeps embeddings up to date.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="Synch Period Minutes"
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(
|
||||
formState.errors.synchPeriodMinutes?.message,
|
||||
)}
|
||||
helperText={formState.errors.synchPeriodMinutes?.message}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useUpdateConfigMutation } from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DisableAIServiceConfirmationDialogProps {
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Function to be called when the user clicks the confirm button.
|
||||
*/
|
||||
onServiceDisabled: () => void;
|
||||
}
|
||||
|
||||
export default function DisableAIServiceConfirmationDialog({
|
||||
onCancel,
|
||||
onServiceDisabled,
|
||||
}: DisableAIServiceConfirmationDialogProps) {
|
||||
const { closeDialog } = useDialog();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
|
||||
await toast.promise(
|
||||
updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
ai: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Disabling the AI service...',
|
||||
success: () => {
|
||||
onServiceDisabled();
|
||||
closeDialog();
|
||||
return `The service has been disabled.`;
|
||||
},
|
||||
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 disabling the AI service. Please try again later.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 pt-0 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to disable this service?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button color="error" onClick={handleClick} loading={loading}>
|
||||
Disable
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
2
dashboard/src/features/ai/settings/components/index.ts
Normal file
2
dashboard/src/features/ai/settings/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AISettings';
|
||||
export { default as AISettings } from './AISettings';
|
||||
21
dashboard/src/features/ai/settings/gql/getAISettings.gql
Normal file
21
dashboard/src/features/ai/settings/gql/getAISettings.gql
Normal file
@@ -0,0 +1,21 @@
|
||||
query GetAISettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
ai {
|
||||
version
|
||||
webhookSecret
|
||||
autoEmbeddings {
|
||||
synchPeriodMinutes
|
||||
}
|
||||
openai {
|
||||
apiKey
|
||||
organization
|
||||
}
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,9 @@ import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariab
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import '@testing-library/jest-dom';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
const server = setupServer(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user