Compare commits
252 Commits
@nhost/das
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d9fd1a54a5 | ||
|
|
a19b85c8ac | ||
|
|
4e1aaca0ee | ||
|
|
34ef37cdce | ||
|
|
5d6b655cb1 | ||
|
|
074a0fa111 | ||
|
|
403d839fca | ||
|
|
4e3098240b | ||
|
|
dd0a5cf3c1 | ||
|
|
5187fd3a4b | ||
|
|
d8dfd6bf80 | ||
|
|
6ea6ad61db | ||
|
|
fd0b904ed4 | ||
|
|
8989e314a6 | ||
|
|
5b5a1219c5 | ||
|
|
07fda9bbb3 | ||
|
|
2fa828fef1 | ||
|
|
d5ec69ac37 | ||
|
|
4a7ede11e9 | ||
|
|
482ae4c4f1 | ||
|
|
08fe4cd65f | ||
|
|
5781721bca | ||
|
|
39de0063bf | ||
|
|
202b647234 | ||
|
|
51c163a268 | ||
|
|
6e802c9938 | ||
|
|
9a46104e37 | ||
|
|
655b317c39 | ||
|
|
d3ad7c9d4a | ||
|
|
09fc852c3a | ||
|
|
ece08d3efd | ||
|
|
3493442c2d | ||
|
|
632a79b9e4 | ||
|
|
4a4d85757a | ||
|
|
88a01004b7 | ||
|
|
73230eb35a | ||
|
|
27e1c90624 | ||
|
|
1cc53d550a | ||
|
|
22d3f71e02 | ||
|
|
010b816866 | ||
|
|
4a6e62e673 | ||
|
|
5cf9dd9bc2 | ||
|
|
27e74c10d7 | ||
|
|
bd807a5ee1 | ||
|
|
4093e03a13 | ||
|
|
29076d0304 | ||
|
|
ab83fa6b5e | ||
|
|
b20761e976 | ||
|
|
a445e5b786 | ||
|
|
90df6d81d8 | ||
|
|
aa85084675 | ||
|
|
07ad470c0c | ||
|
|
fa6b58a9c5 | ||
|
|
acf55376ba | ||
|
|
b0a9798b04 | ||
|
|
3952e87f01 | ||
|
|
b95ccf873d | ||
|
|
8d7f84b8da | ||
|
|
bd1b69bd75 | ||
|
|
84d5436634 | ||
|
|
2325766c1d | ||
|
|
2c355eaae4 | ||
|
|
9e26ed767e | ||
|
|
abdb6c56f4 | ||
|
|
3b75bfce27 | ||
|
|
f498190758 | ||
|
|
b4158fa513 | ||
|
|
3d1a177632 | ||
|
|
0675a213b5 | ||
|
|
a8ff383490 | ||
|
|
960d815f68 | ||
|
|
edf2b4e93f | ||
|
|
fe240542a4 | ||
|
|
c7752c0657 | ||
|
|
d1e2b1c75a | ||
|
|
bcdab66bf8 | ||
|
|
7636f40030 | ||
|
|
e643bd3620 | ||
|
|
311c7756d7 | ||
|
|
f967a2e596 | ||
|
|
4c4b253a71 | ||
|
|
0f5f8c0d90 | ||
|
|
37a7fc05d5 | ||
|
|
bf93d87b36 | ||
|
|
efb3dc7294 |
@@ -6,5 +6,5 @@
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
"ignore": ["@nhost-examples/sveltekit"]
|
||||
}
|
||||
|
||||
54
.github/workflows/changesets.yaml
vendored
54
.github/workflows/changesets.yaml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
commit: 'chore: update versions'
|
||||
title: 'chore: update versions'
|
||||
publish: pnpm run release
|
||||
createGithubReleases: true
|
||||
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
|
||||
@@ -124,32 +152,6 @@ jobs:
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -146,7 +146,7 @@ jobs:
|
||||
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
|
||||
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 7
|
||||
timeout-minutes: 15
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,132 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 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
|
||||
|
||||
- 4a7ede11e: fix: distinguish files that were not uploaded
|
||||
- 202b64723: feat(nhost-run): add support for one-click-install run services
|
||||
- 074a0fa11: feat(dashboard): add settings toggle to enable/disable antivirus
|
||||
- @nhost/react-apollo@5.0.33
|
||||
- @nhost/nextjs@1.13.35
|
||||
|
||||
## 0.20.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b20761e97: feat(services): add pricing info and confirmation dialog
|
||||
- 90df6d81d: fix(services): handle null values when editing a service
|
||||
- aa8508467: fix: query service logs correctly
|
||||
feat: enable multiline support for environment value input
|
||||
|
||||
## 0.20.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8d7f84b8d: fix: make announcement adapt to theme
|
||||
|
||||
## 0.20.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3b75bfce2: fix: make announcement close properly
|
||||
- f49819075: fix: show correct values when dedicated resources are disabled
|
||||
|
||||
## 0.20.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e643bd362: fix(services): fix errors when config is null
|
||||
- bcdab66bf: feat: add annoucement for nhost run
|
||||
- f967a2e59: added note about storage not being able to be downsized
|
||||
- 311c7756d: chore(services): consistent naming for compute
|
||||
|
||||
## 0.20.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -30,7 +30,7 @@ test('should show a sidebar with menu items', async () => {
|
||||
const navLocator = page.getByRole('navigation', { name: /main navigation/i });
|
||||
await expect(navLocator).toBeVisible();
|
||||
await expect(navLocator.getByRole('list').getByRole('listitem')).toHaveCount(
|
||||
11,
|
||||
12,
|
||||
);
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /overview/i }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.2",
|
||||
"version": "0.20.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -66,6 +66,7 @@
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-intersection-observer": "^9.5.2",
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
@@ -105,6 +106,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
@@ -0,0 +1,92 @@
|
||||
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: 'node-18',
|
||||
href: 'https://github.com/nhost/nhost/discussions/2239',
|
||||
content:
|
||||
"Starting October 1st, we're upgrading to Node.js 18 for improved performance, security, and stability. Learn more.",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
3
dashboard/src/components/common/Announcement/index.ts
Normal file
3
dashboard/src/components/common/Announcement/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Announcement';
|
||||
export * from './AnnouncementProvider';
|
||||
export { default as useAnnouncement } from './useAnnouncement';
|
||||
@@ -0,0 +1,14 @@
|
||||
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';
|
||||
@@ -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';
|
||||
@@ -178,6 +178,22 @@ export default function DataGridBody<T extends object>({
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundCellColor = (
|
||||
row: Row<T>,
|
||||
column: DataBrowserGridColumn<T>,
|
||||
) => {
|
||||
// Grey out files not uploaded
|
||||
if (!row.values.isUploaded) {
|
||||
return 'grey.200';
|
||||
}
|
||||
|
||||
if (column.isDisabled) {
|
||||
return 'grey.100';
|
||||
}
|
||||
|
||||
return 'background.paper';
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||
{rows.length === 0 && !loading && (
|
||||
@@ -260,9 +276,7 @@ export default function DataGridBody<T extends object>({
|
||||
})}
|
||||
cell={cell}
|
||||
sx={{
|
||||
backgroundColor: column.isDisabled
|
||||
? 'grey.100'
|
||||
: 'background.paper',
|
||||
backgroundColor: getBackgroundCellColor(row, column),
|
||||
color: isCellDisabled ? 'text.secondary' : 'text.primary',
|
||||
}}
|
||||
className={twMerge(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
|
||||
@@ -54,7 +54,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>
|
||||
@@ -75,14 +75,14 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
hideChevron
|
||||
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
>
|
||||
Feedback
|
||||
Contact us
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<FeedbackForm className="max-w-md" />
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
|
||||
import { Nav } from '@/components/presentational/Nav';
|
||||
@@ -171,7 +171,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
className="w-full"
|
||||
role={undefined}
|
||||
>
|
||||
<ListItem.Text>Feedback</ListItem.Text>
|
||||
<ListItem.Text>Contact us</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
</Dropdown.Trigger>
|
||||
@@ -180,7 +180,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm className="max-w-md" />
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -38,6 +38,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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetHasuraSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -30,16 +32,6 @@ export type HasuraServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_HASURA_VERSIONS = [
|
||||
'v2.29.0-ce',
|
||||
'v2.28.2-ce',
|
||||
'v2.27.0-ce',
|
||||
'v2.25.1-ce',
|
||||
'v2.25.0-ce',
|
||||
'v2.24.1-ce',
|
||||
'v2.15.2',
|
||||
];
|
||||
|
||||
export default function HasuraServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
@@ -53,9 +45,16 @@ export default function HasuraServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: hasuraVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Hasura,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.hasura || {};
|
||||
const versions = hasuraVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_HASURA_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
@@ -99,7 +99,7 @@ export default function AppLoader({
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
@@ -250,7 +250,7 @@ export default function ApplicationErrored() {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
@@ -65,7 +65,7 @@ export default function ApplicationUnknown() {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { type RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteServiceModalProps {
|
||||
service: RunService;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteServiceModal({
|
||||
service,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteServiceModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async () => {
|
||||
await deleteRunService({ variables: { serviceID: service.id } });
|
||||
await deleteRunServiceConfig({
|
||||
variables: { appID: currentProject.id, serviceID: service.id },
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(),
|
||||
{
|
||||
loading: 'Deleting the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Service {service?.config?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this service?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete ${service?.config?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Project #1"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Service
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DeleteServiceModal';
|
||||
export { default as DeleteServiceModal } from './DeleteServiceModal';
|
||||
@@ -111,7 +111,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
awsName: null,
|
||||
domain: null,
|
||||
},
|
||||
isProvisioned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useHostName } from './useHostName';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useHostName() {
|
||||
const [hostName, setHostName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const { port, hostname, protocol } = window.location;
|
||||
setHostName(`${protocol}//${hostname}:${port}`);
|
||||
}, []);
|
||||
|
||||
return hostName;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type { SvgIconProps } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useHypertune } from '@/hooks/useHypertune';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export interface ProjectRoute {
|
||||
@@ -58,26 +57,8 @@ export interface ProjectRoute {
|
||||
export default function useProjectRoutes() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
loading: currentProjectLoading,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
const hypertune = useHypertune();
|
||||
|
||||
const enableServices =
|
||||
currentWorkspace &&
|
||||
hypertune
|
||||
.root({
|
||||
context: {
|
||||
workSpace: {
|
||||
id: currentWorkspace.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.enableServices({})
|
||||
.get(false);
|
||||
const { currentProject, loading: currentProjectLoading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const nhostRoutes: ProjectRoute[] = [
|
||||
{
|
||||
@@ -118,7 +99,7 @@ export default function useProjectRoutes() {
|
||||
},
|
||||
];
|
||||
|
||||
let allRoutes: ProjectRoute[] = [
|
||||
const allRoutes: ProjectRoute[] = [
|
||||
{
|
||||
relativePath: '/',
|
||||
exact: true,
|
||||
@@ -156,18 +137,14 @@ export default function useProjectRoutes() {
|
||||
label: 'Storage',
|
||||
icon: <StorageIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
if (enableServices) {
|
||||
allRoutes.push({
|
||||
{
|
||||
relativePath: '/services',
|
||||
exact: false,
|
||||
label: 'Run',
|
||||
icon: <ServicesIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
allRoutes = [...allRoutes, ...nhostRoutes];
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
return {
|
||||
nhostRoutes,
|
||||
|
||||
@@ -128,6 +128,8 @@ export default function BaseEnvironmentVariableForm({
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
@@ -154,10 +154,12 @@ export default function LogsHeader({
|
||||
const services = data.app?.runServices ?? [];
|
||||
|
||||
setRunServices(
|
||||
services.map((s) => ({
|
||||
label: s.config.name,
|
||||
value: s.config.name,
|
||||
})),
|
||||
services
|
||||
.filter((s) => !!s.config?.name)
|
||||
.map((s) => ({
|
||||
label: s.config.name,
|
||||
value: `run-${s.config.name}`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [loading, data]);
|
||||
|
||||
@@ -41,11 +41,6 @@ export default function OverviewMetrics() {
|
||||
numberOfDecimals: 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Egress Volume',
|
||||
tooltip: 'Amount of data your services have sent to users',
|
||||
value: prettifySize(data?.egressVolume?.value || 0),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
tooltip: 'Amount of logs stored',
|
||||
|
||||
@@ -96,7 +96,7 @@ export function OverviewUsageMetrics() {
|
||||
remoteAppMetricsData?.filesAggregate?.aggregate?.sum?.size || 0;
|
||||
const totalStorage = currentProject?.plan?.isFree
|
||||
? 1 * 1000 ** 3 // 1 GB
|
||||
: 10 * 1000 ** 3; // 10 GB
|
||||
: 50 * 1000 ** 3; // 10 GB
|
||||
|
||||
// metrics for users
|
||||
const usedUsers = remoteAppMetricsData?.usersAggregate?.aggregate?.count || 0;
|
||||
@@ -105,6 +105,16 @@ export function OverviewUsageMetrics() {
|
||||
// metrics for functions
|
||||
const usedFunctions = functionsInfoData?.app.metadataFunctions.length || 0;
|
||||
const totalFunctions = currentProject?.plan?.isFree ? 10 : 50;
|
||||
const usedFunctionsDuration = projectMetrics?.functionsDuration.value || 0;
|
||||
const totalFunctionsDuration = currentProject?.plan?.isFree
|
||||
? 3600 // 1 hour
|
||||
: 3600 * 10; // 10 hours
|
||||
|
||||
// metrics for egress
|
||||
const usedEgressVolume = projectMetrics?.egressVolume.value || 0;
|
||||
const totalEgressVolume = currentProject?.plan?.isFree
|
||||
? 5 * 1000 ** 3 // 5 GB
|
||||
: 50 * 1000 ** 3; // 50 GB
|
||||
|
||||
if (metricsLoading) {
|
||||
return (
|
||||
@@ -112,7 +122,9 @@ export function OverviewUsageMetrics() {
|
||||
<UsageProgress label="Database" percentage={0} />
|
||||
<UsageProgress label="Storage" percentage={0} />
|
||||
<UsageProgress label="Users" percentage={0} />
|
||||
<UsageProgress label="Functions" percentage={0} />
|
||||
<UsageProgress label="Number of Functions" percentage={0} />
|
||||
<UsageProgress label="Functions Execution Time" percentage={0} />
|
||||
<UsageProgress label="Egress Volume" percentage={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,6 +151,18 @@ export function OverviewUsageMetrics() {
|
||||
used={usedFunctions}
|
||||
percentage={100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions"
|
||||
used={usedFunctionsDuration}
|
||||
percentage={100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Egress"
|
||||
used={usedEgressVolume}
|
||||
percentage={100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,11 +191,25 @@ export function OverviewUsageMetrics() {
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions"
|
||||
label="Number of Functions"
|
||||
used={usedFunctions}
|
||||
total={totalFunctions}
|
||||
percentage={(usedFunctions / totalFunctions) * 100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions Execution Time"
|
||||
used={Math.trunc(usedFunctionsDuration)}
|
||||
total={`${totalFunctionsDuration} seconds`}
|
||||
percentage={(usedFunctionsDuration / totalFunctionsDuration) * 100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Egress Volume"
|
||||
used={prettifySize(usedEgressVolume)}
|
||||
total={prettifySize(totalEgressVolume)}
|
||||
percentage={(usedEgressVolume / totalEgressVolume) * 100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PermissionVariable } from '@/types/application';
|
||||
import { expect, test } from 'vitest';
|
||||
import getAllPermissionVariables from './getAllPermissionVariables';
|
||||
|
||||
test('should convert permission variable object to array', () => {
|
||||
|
||||
@@ -68,11 +68,15 @@ export default function ResourcesConfirmationDialog({
|
||||
const totalBillableVCPU = formValues.enabled ? billableResources.vcpu : 0;
|
||||
const totalBillableMemory = formValues.enabled ? billableResources.memory : 0;
|
||||
|
||||
const updatedPrice =
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
const { enabled } = formValues;
|
||||
|
||||
const updatedPrice = enabled
|
||||
? Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price
|
||||
: proPlan.price;
|
||||
|
||||
if (!loading && !proPlan) {
|
||||
return (
|
||||
@@ -86,18 +90,30 @@ export default function ResourcesConfirmationDialog({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const databaseVCPU = enabled ? formValues.database.vcpu : 0;
|
||||
const databaseMemory = enabled ? formValues.database.memory : 0;
|
||||
|
||||
const hasuraVCPU = enabled ? formValues.hasura.vcpu : 0;
|
||||
const hasuraMemory = enabled ? formValues.hasura.memory : 0;
|
||||
|
||||
const authVCPU = enabled ? formValues.auth.vcpu : 0;
|
||||
const authMemory = enabled ? formValues.auth.memory : 0;
|
||||
|
||||
const storageVCPU = enabled ? formValues.storage.vcpu : 0;
|
||||
const storageMemory = enabled ? formValues.storage.memory : 0;
|
||||
|
||||
const databaseResources = `${prettifyVCPU(
|
||||
formValues.database.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.database.memory)}`;
|
||||
const hasuraResources = `${prettifyVCPU(
|
||||
formValues.hasura.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.hasura.memory)}`;
|
||||
const authResources = `${prettifyVCPU(
|
||||
formValues.auth.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.auth.memory)}`;
|
||||
databaseVCPU,
|
||||
)} vCPU + ${prettifyMemory(databaseMemory)}`;
|
||||
const hasuraResources = `${prettifyVCPU(hasuraVCPU)} vCPU + ${prettifyMemory(
|
||||
hasuraMemory,
|
||||
)}`;
|
||||
const authResources = `${prettifyVCPU(authVCPU)} vCPU + ${prettifyMemory(
|
||||
authMemory,
|
||||
)}`;
|
||||
const storageResources = `${prettifyVCPU(
|
||||
formValues.storage.vcpu,
|
||||
)} vCPU + ${prettifyMemory(formValues.storage.memory)}`;
|
||||
storageVCPU,
|
||||
)} vCPU + ${prettifyMemory(storageMemory)}`;
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { Slider, sliderClasses } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useProPlan } from '@/features/projects/common/hooks/useProPlan';
|
||||
import { calculateBillableResources } from '@/features/projects/resources/settings/utils/calculateBillableResources';
|
||||
import { getAllocatedResources } from '@/features/projects/resources/settings/utils/getAllocatedResources';
|
||||
import { prettifyMemory } from '@/features/projects/resources/settings/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/projects/resources/settings/utils/prettifyVCPU';
|
||||
@@ -63,34 +62,7 @@ export default function TotalResourcesFormFragment({
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice =
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
const updatedPrice = priceForTotalAvailableVCPU + proPlan.price;
|
||||
|
||||
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
|
||||
getAllocatedResources(formValues);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from 'vitest';
|
||||
import { expect, test } from 'vitest';
|
||||
import getAllocatedResources from './getAllocatedResources';
|
||||
|
||||
test('should return the total number of allocated resources', () => {
|
||||
|
||||
@@ -155,3 +155,4 @@ export const MIN_SERVICES_CPU = Math.floor(128 / MEM_CPU_RATIO);
|
||||
export const MIN_SERVICES_MEM = 128;
|
||||
export const MAX_SERVICES_CPU = 7000;
|
||||
export const MAX_SERVICES_MEM = Math.floor(MAX_SERVICES_CPU * MEM_CPU_RATIO);
|
||||
export const COST_PER_VCPU = 0.05;
|
||||
|
||||
@@ -3,13 +3,17 @@ import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
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 { useHostName } from '@/features/projects/common/hooks/useHostName';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
COST_PER_VCPU,
|
||||
MAX_SERVICES_CPU,
|
||||
MAX_SERVICES_MEM,
|
||||
MAX_SERVICE_REPLICAS,
|
||||
@@ -22,7 +26,9 @@ import { PortsFormSection } from '@/features/services/components/ServiceForm/com
|
||||
import { ReplicasFormSection } from '@/features/services/components/ServiceForm/components/ReplicasFormSection';
|
||||
import { StorageFormSection } from '@/features/services/components/ServiceForm/components/StorageFormSection';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
@@ -36,6 +42,8 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { parse } from 'shell-quote';
|
||||
import * as Yup from 'yup';
|
||||
import { ServiceConfirmationDialog } from './components/ServiceConfirmationDialog';
|
||||
import { ServiceDetailsDialog } from './components/ServiceDetailsDialog';
|
||||
|
||||
export enum PortTypes {
|
||||
HTTP = 'http',
|
||||
@@ -87,7 +95,7 @@ export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: ServiceFormValues;
|
||||
initialData?: ServiceFormValues & { subdomain?: string }; // subdomain is only set on the backend
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
@@ -106,11 +114,16 @@ export default function ServiceForm({
|
||||
onCancel,
|
||||
location,
|
||||
}: ServiceFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const hostName = useHostName();
|
||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation();
|
||||
const [detailsServiceId, setDetailsServiceId] = useState('');
|
||||
const [detailsServiceSubdomain, setDetailsServiceSubdomain] = useState(
|
||||
initialData?.subdomain,
|
||||
);
|
||||
|
||||
const [createServiceFormError, setCreateServiceFormError] =
|
||||
useState<Error | null>(null);
|
||||
@@ -133,6 +146,8 @@ export default function ServiceForm({
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
const serviceImage = watch('image');
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
@@ -141,7 +156,7 @@ export default function ServiceForm({
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateService = async (values: ServiceFormValues) => {
|
||||
const getFormattedConfig = (values: ServiceFormValues) => {
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: values.name,
|
||||
image: {
|
||||
@@ -171,7 +186,13 @@ export default function ServiceForm({
|
||||
})),
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
return config;
|
||||
};
|
||||
|
||||
const createOrUpdateService = async (values: ServiceFormValues) => {
|
||||
const config = getFormattedConfig(values);
|
||||
|
||||
if (serviceID) {
|
||||
// Update service config
|
||||
await replaceRunServiceConfig({
|
||||
variables: {
|
||||
@@ -180,11 +201,13 @@ export default function ServiceForm({
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(serviceID);
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID },
|
||||
insertRunService: { id: newServiceID, subdomain },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
@@ -211,6 +234,9 @@ export default function ServiceForm({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(newServiceID);
|
||||
setDetailsServiceSubdomain(subdomain);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -238,18 +264,75 @@ export default function ServiceForm({
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
// await refetchWorkspaceAndProject();
|
||||
// refestch the services
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = (values: ServiceFormValues) => {
|
||||
openDialog({
|
||||
title: 'Confirm Resources',
|
||||
component: (
|
||||
<ServiceConfirmationDialog
|
||||
formValues={values}
|
||||
onCancel={closeDialog}
|
||||
onSubmit={async () => {
|
||||
await handleSubmit(formValues);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (detailsServiceId) {
|
||||
openDialog({
|
||||
title: 'Service Details',
|
||||
component: (
|
||||
<ServiceDetailsDialog
|
||||
serviceID={detailsServiceId}
|
||||
subdomain={detailsServiceSubdomain}
|
||||
ports={formValues.ports}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [detailsServiceId, detailsServiceSubdomain, formValues, openDialog]);
|
||||
|
||||
const pricingExplanation = () => {
|
||||
const vCPUs = `${formValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER} vCPUs`;
|
||||
const mem = `${formValues.compute.memory} MiB Mem`;
|
||||
let details = `${vCPUs} + ${mem}`;
|
||||
|
||||
if (formValues.replicas > 1) {
|
||||
details = `(${details}) x ${formValues.replicas} replicas`;
|
||||
}
|
||||
|
||||
return `Approximate cost for ${details}`;
|
||||
};
|
||||
|
||||
const copyConfig = () => {
|
||||
const config = getFormattedConfig(formValues);
|
||||
|
||||
const base64Config = btoa(JSON.stringify(config));
|
||||
|
||||
const link = `${hostName}/run-one-click-install?config=${base64Config}`;
|
||||
|
||||
copy(link, 'Service Config');
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleConfirm}
|
||||
className="grid grid-flow-row gap-4 px-6 pb-6"
|
||||
>
|
||||
<Input
|
||||
@@ -346,6 +429,23 @@ export default function ServiceForm({
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
className="flex items-center justify-between space-x-2"
|
||||
>
|
||||
<span>{pricingExplanation()}</span>
|
||||
<b>
|
||||
$
|
||||
{parseFloat(
|
||||
(
|
||||
formValues.compute.cpu *
|
||||
formValues.replicas *
|
||||
COST_PER_VCPU
|
||||
).toFixed(2),
|
||||
)}
|
||||
</b>
|
||||
</Alert>
|
||||
|
||||
<ComputeFormSection />
|
||||
|
||||
<ReplicasFormSection />
|
||||
@@ -378,9 +478,24 @@ export default function ServiceForm({
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{initialData ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<PlusIcon />}
|
||||
>
|
||||
{serviceID ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={copyConfig}
|
||||
startIcon={<CopyIcon />}
|
||||
>
|
||||
Copy one-click install link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
|
||||
@@ -48,7 +48,8 @@ export default function ComputeFormSection() {
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
CPU: {formValues.compute.cpu} / Memory: {formValues.compute.memory}
|
||||
vCPUs: {formValues.compute.cpu / 1000} / Memory:{' '}
|
||||
{formValues.compute.memory}
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useState } from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function EnvironmentFormSection() {
|
||||
@@ -15,6 +16,8 @@ export default function EnvironmentFormSection() {
|
||||
formState: { errors },
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
|
||||
const [focusedInput, setFocusedInput] = useState<string>(null);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'environment',
|
||||
});
|
||||
@@ -49,42 +52,42 @@ export default function EnvironmentFormSection() {
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box
|
||||
key={field.id}
|
||||
className="flex w-full flex-col space-y-2 xs+:flex-row xs+:space-y-0 xs+:space-x-2"
|
||||
>
|
||||
<Input
|
||||
{...register(`environment.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label={!index && 'Name'}
|
||||
placeholder={`Key ${index}`}
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.environment?.at(index)}
|
||||
helperText={errors?.environment?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register(`environment.${index}.value`)}
|
||||
id={`${field.id}-value`}
|
||||
label={!index && 'Value'}
|
||||
placeholder={`Value ${index}`}
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.environment?.at(index)}
|
||||
helperText={errors?.environment?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Box key={field.id} className="flex w-full items-center space-x-2">
|
||||
<div className="flex w-full flex-col space-y-2">
|
||||
<Input
|
||||
{...register(`environment.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
placeholder={`Key ${index}`}
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.environment?.at(index)}
|
||||
helperText={errors?.environment?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register(`environment.${index}.value`)}
|
||||
id={`${field.id}-value`}
|
||||
placeholder={`Value ${index}`}
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.environment?.at(index)}
|
||||
helperText={errors?.environment?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
maxRows={focusedInput === `${field.id}-value` ? 1000 : 1}
|
||||
onFocusCapture={() => setFocusedInput(`${field.id}-value`)}
|
||||
onBlurCapture={() => setFocusedInput(null)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="h-6 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -32,20 +32,20 @@ export default function PortsFormSection() {
|
||||
name: 'ports',
|
||||
});
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
const formValues = useWatch<ServiceFormValues & { subdomain: string }>();
|
||||
|
||||
const onChangePortType = (value: string | undefined, index: number) =>
|
||||
setValue(`ports.${index}.type`, value as PortTypes);
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.subdomain &&
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, _name: string) => {
|
||||
const getPortURL = (_port: string | number, subdomain: string) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
const name = _name && _name.length > 0 ? _name : '[name]';
|
||||
|
||||
return `https://${currentProject.subdomain}-${name}-${port}.svc.${currentProject.region.awsName}.${currentProject.region.domain}`;
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -144,7 +144,7 @@ export default function PortsFormSection() {
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.name,
|
||||
formValues.subdomain,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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 { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
|
||||
export interface ServiceConfirmationDialogProps {
|
||||
/**
|
||||
* The updated resources that the user has selected.
|
||||
*/
|
||||
formValues: ServiceFormValues;
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Function to be called when the user clicks the confirm button.
|
||||
*/
|
||||
onSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ServiceConfirmationDialog({
|
||||
formValues,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ServiceConfirmationDialogProps) {
|
||||
const approximatePriceForService = parseFloat(
|
||||
(formValues.compute.cpu * formValues.replicas * COST_PER_VCPU).toFixed(2),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
<Box className="grid grid-flow-row gap-4">
|
||||
<Box className="grid grid-flow-row gap-1.5">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text color="secondary">vCPUs</Text>
|
||||
</Box>
|
||||
<Text>{formValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER}</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text color="secondary">Memory</Text>
|
||||
</Box>
|
||||
<Text>{formValues.compute.memory} MiB</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text color="secondary">Replicas</Text>
|
||||
</Box>
|
||||
<Text>{formValues.replicas}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Box className="grid grid-flow-col items-center gap-1.5">
|
||||
<Text className="font-medium">Approximate Cost</Text>
|
||||
|
||||
<Tooltip title="$0.0012/minute for every 1 vCPU and 2 GiB of RAM">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Text>${approximatePriceForService}/mo</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button color="primary" onClick={onSubmit} autoFocus>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
<Button variant="borderless" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceConfirmationDialog';
|
||||
export { default as ServiceConfirmationDialog } from './ServiceConfirmationDialog';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import type { ConfigRunServicePort } from '@/utils/__generated__/graphql';
|
||||
|
||||
export interface ServiceDetailsDialogProps {
|
||||
/**
|
||||
* The id of the service
|
||||
*/
|
||||
serviceID: string;
|
||||
|
||||
/**
|
||||
* The subdomain of the service
|
||||
*/
|
||||
subdomain: string;
|
||||
|
||||
/**
|
||||
* The service ports
|
||||
* We use partial here because `port` is set as required in ConfigRunServicePort
|
||||
*/
|
||||
ports: Partial<ConfigRunServicePort>[];
|
||||
}
|
||||
|
||||
export default function ServiceDetailsDialog({
|
||||
serviceID,
|
||||
subdomain,
|
||||
ports,
|
||||
}: ServiceDetailsDialogProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const getPortURL = (_port: string | number) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-6 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Private registry</Text>
|
||||
<InfoCard
|
||||
title=""
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ports?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Ports</Text>
|
||||
{ports
|
||||
.filter((port) => port.publish)
|
||||
.map((port) => (
|
||||
<InfoCard
|
||||
title={`${port.type}:${port.port}`}
|
||||
value={getPortURL(port.port)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
color="primary"
|
||||
onClick={() => closeDialog()}
|
||||
autoFocus
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceDetailsDialog';
|
||||
export { default as ServiceDetailsDialog } from './ServiceDetailsDialog';
|
||||
@@ -52,7 +52,8 @@ export default function StorageFormSection() {
|
||||
title={
|
||||
<span>
|
||||
By default, services do not have persistent storage. You can add
|
||||
SSD disks to the service here. Refer to{' '}
|
||||
SSD disks to the service here. It is important to note that
|
||||
capacity can not be decreased after creation, only expanded. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -10,21 +10,14 @@ import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { DeleteServiceModal } from '@/features/projects/common/components/DeleteServiceModal';
|
||||
import {
|
||||
ServiceForm,
|
||||
type PortTypes,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ServicesListProps {
|
||||
/**
|
||||
@@ -51,30 +44,14 @@ export default function ServicesList({
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: ServicesListProps) {
|
||||
const { openDrawer } = useDialog();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async (appID: string, serviceID: string) => {
|
||||
await deleteRunService({ variables: { serviceID } });
|
||||
await deleteRunServiceConfig({ variables: { appID, serviceID } });
|
||||
await onDelete?.();
|
||||
};
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewService = async (service: RunService) => {
|
||||
const {
|
||||
image,
|
||||
command,
|
||||
ports,
|
||||
resources: { compute, replicas, storage },
|
||||
} = service.config;
|
||||
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Edit {service.config.name}</Text>
|
||||
<Text>Edit {service.config?.name ?? 'unset'}</Text>
|
||||
</Box>
|
||||
),
|
||||
component: (
|
||||
@@ -82,16 +59,20 @@ export default function ServicesList({
|
||||
serviceID={service.id}
|
||||
initialData={{
|
||||
...service.config,
|
||||
image: image.image,
|
||||
command: command?.join(' '),
|
||||
ports: ports.map((item) => ({
|
||||
image: service.config?.image?.image,
|
||||
subdomain: service.subdomain,
|
||||
command: service.config?.command?.join(' '),
|
||||
ports: service.config?.ports?.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
})),
|
||||
compute,
|
||||
replicas,
|
||||
storage,
|
||||
compute: service.config?.resources?.compute ?? {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
replicas: service.config?.resources?.replicas,
|
||||
storage: service.config?.resources?.storage,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
@@ -99,28 +80,16 @@ export default function ServicesList({
|
||||
});
|
||||
};
|
||||
|
||||
const deleteService = async (serviceID: string) => {
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(currentProject.id, serviceID),
|
||||
{
|
||||
loading: 'Deleteing the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
const deleteService = async (service: RunService) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteServiceModal
|
||||
service={service}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -146,7 +115,7 @@ export default function ServicesList({
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{service.config.name}
|
||||
{service.config?.name ?? 'unset'}
|
||||
</Text>
|
||||
<Tooltip title={service.updatedAt}>
|
||||
<span className="hidden cursor-pointer text-sm text-slate-500 xs+:flex">
|
||||
@@ -207,7 +176,7 @@ export default function ServicesList({
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteService(service.id)}
|
||||
onClick={() => deleteService(service)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
|
||||
@@ -7,6 +7,8 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetStorageSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useGetStorageSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
@@ -30,8 +32,6 @@ export type StorageServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_STORAGE_VERSIONS = ['0.3.5', '0.3.4', '0.3.3', '0.3.2'];
|
||||
|
||||
export default function StorageServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -44,9 +44,16 @@ export default function StorageServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: storageVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Storage,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.storage || {};
|
||||
const versions = storageVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_STORAGE_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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 { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetStorageSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type HasuraStorageAVFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraStorageAVSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetStorageSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { server } = data?.config?.storage?.antivirus || {};
|
||||
|
||||
const form = useForm<HasuraStorageAVFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled: !!server,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading AV settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: HasuraStorageAVFormValues) {
|
||||
let antivirus = null;
|
||||
|
||||
if (formValues.enabled) {
|
||||
antivirus = {
|
||||
server: 'tcp://run-clamav:3310',
|
||||
};
|
||||
}
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
storage: {
|
||||
antivirus,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Antivirus settings are being updated...`,
|
||||
success: `Antivirus settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update Antivirus settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Antivirus"
|
||||
description="Enable or disable Antivirus."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !form.formState.isDirty || maintenanceActive,
|
||||
loading: form.formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
switchId="enabled"
|
||||
docsTitle="enabling or disabling Antivirus"
|
||||
showSwitch
|
||||
className="hidden"
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraStorageAVSettings';
|
||||
export { default as HasuraStorageAVSettings } from './HasuraStorageAVSettings';
|
||||
@@ -4,6 +4,9 @@ query GetStorageSettings($appId: uuid!) {
|
||||
__typename
|
||||
storage {
|
||||
version
|
||||
antivirus {
|
||||
server
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
dashboard/src/gql/app/getProjectLocales.graphql
Normal file
12
dashboard/src/gql/app/getProjectLocales.graphql
Normal file
@@ -0,0 +1,12 @@
|
||||
query getProjectLocales($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
auth {
|
||||
user {
|
||||
locale {
|
||||
allowed
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ query GetProjectMetrics(
|
||||
) {
|
||||
value
|
||||
}
|
||||
functionsDuration: getFunctionsDuration(appID: $appId, from: $from, to: $to) {
|
||||
value
|
||||
}
|
||||
postgresVolumeCapacity: getPostgresVolumeCapacity(appID: $appId) {
|
||||
value
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ fragment Project on apps {
|
||||
name
|
||||
repositoryProductionBranch
|
||||
subdomain
|
||||
isProvisioned
|
||||
createdAt
|
||||
desiredState
|
||||
nhostBaseFolder
|
||||
|
||||
9
dashboard/src/gql/platform/getSoftwareVersions.gql
Normal file
9
dashboard/src/gql/platform/getSoftwareVersions.gql
Normal file
@@ -0,0 +1,9 @@
|
||||
query getSoftwareVersions($software: software_type_enum!) {
|
||||
softwareVersions(
|
||||
where: { software: { _eq: $software } }
|
||||
order_by: { version: desc }
|
||||
) {
|
||||
version
|
||||
software
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
query getRunService($id: uuid!, $resolve: Boolean!) {
|
||||
runService(id: $id) {
|
||||
id
|
||||
subdomain
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
|
||||
@@ -9,6 +9,7 @@ query getRunServices(
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
subdomain
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mutation insertRunService($object: run_service_insert_input!) {
|
||||
insertRunService(object: $object) {
|
||||
id
|
||||
appID
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,35 @@ import type { GetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import { ServiceForm } from '@/features/services/components/ServiceForm';
|
||||
import {
|
||||
ServiceForm,
|
||||
type PortTypes,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import ServicesList from '@/features/services/components/ServicesList/ServicesList';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
export type RunService = Omit<
|
||||
GetRunServicesQuery['app']['runServices'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export type RunServiceConfig = Omit<
|
||||
GetRunServicesQuery['app']['runServices'][0]['config'],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function ServicesPage() {
|
||||
const limit = useRef(25);
|
||||
const router = useRouter();
|
||||
const { openDrawer } = useDialog();
|
||||
const { openDrawer, openAlertDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlanFree = currentProject.plan.isFree;
|
||||
|
||||
@@ -66,6 +81,63 @@ export default function ServicesPage() {
|
||||
[data],
|
||||
);
|
||||
|
||||
const checkConfigFromQuery = useCallback(
|
||||
(base64Config: string) => {
|
||||
if (router.query?.config) {
|
||||
try {
|
||||
const decodedConfig = atob(base64Config);
|
||||
const parsedConfig: RunServiceConfig = JSON.parse(decodedConfig);
|
||||
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Create a new run service</Text>
|
||||
</Box>
|
||||
),
|
||||
component: (
|
||||
<ServiceForm
|
||||
initialData={{
|
||||
...parsedConfig,
|
||||
compute: parsedConfig?.resources?.compute ?? {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
image: parsedConfig?.image?.image,
|
||||
command: parsedConfig?.command?.join(' '),
|
||||
ports: parsedConfig?.ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
})),
|
||||
replicas: parsedConfig?.resources?.replicas,
|
||||
storage: parsedConfig?.resources?.storage,
|
||||
}}
|
||||
onSubmit={refetchServices}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
openAlertDialog({
|
||||
title: 'Configuration not set properly',
|
||||
payload: 'The service configuration was not properly encoded',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[router.query.config, openDrawer, refetchServices, openAlertDialog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query?.config) {
|
||||
checkConfigFromQuery(router.query?.config as string);
|
||||
}
|
||||
}, [checkConfigFromQuery, router.query]);
|
||||
|
||||
const openCreateServiceDialog = () => {
|
||||
openDrawer({
|
||||
title: (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SettingsLayout } from '@/components/layout/SettingsLayout';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { StorageServiceVersionSettings } from '@/features/storage/settings/components/HasuraServiceVersionSettings';
|
||||
import { HasuraStorageAVSettings } from '@/features/storage/settings/components/HasuraStorageAVSettings';
|
||||
import { useGetStorageSettingsQuery } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
@@ -34,6 +35,7 @@ export default function StorageSettingsPage() {
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<StorageServiceVersionSettings />
|
||||
<HasuraStorageAVSettings />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AnnouncementProvider from '@/components/common/Announcement/AnnouncementProvider';
|
||||
import { DialogProvider } from '@/components/common/DialogProvider';
|
||||
import { UIProvider } from '@/components/common/UIProvider';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
@@ -105,7 +106,9 @@ function MyApp({
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<DialogProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
<AnnouncementProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</AnnouncementProvider>
|
||||
</DialogProvider>
|
||||
</RetryableErrorBoundary>
|
||||
</ThemeProvider>
|
||||
|
||||
220
dashboard/src/pages/run-one-click-install.tsx
Normal file
220
dashboard/src/pages/run-one-click-install.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
type GetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Workspace = Omit<
|
||||
GetAllWorkspacesAndProjectsQuery['workspaces'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function SelectWorkspaceAndProject() {
|
||||
const user = useUserData();
|
||||
const router = useRouter();
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const workspaces: Workspace[] = data?.workspaces || [];
|
||||
|
||||
const projects = workspaces.flatMap((workspace) =>
|
||||
workspace.projects.map((project) => ({
|
||||
workspaceName: workspace.name,
|
||||
projectName: project.name,
|
||||
value: `${workspace.slug}/${project.slug}`,
|
||||
isFree: project.plan.isFree,
|
||||
})),
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const checkConfigFromQuery = useCallback(
|
||||
(base64Config: string) => {
|
||||
try {
|
||||
JSON.parse(atob(base64Config));
|
||||
} catch (error) {
|
||||
openAlertDialog({
|
||||
title: 'Configuration not set properly',
|
||||
payload:
|
||||
'Either the link is wrong or the configuration is not properly encoded',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
onPrimaryAction: async () => {
|
||||
await router.push('/');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[openAlertDialog, router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkConfigFromQuery(router.query?.config as string);
|
||||
}, [checkConfigFromQuery, router.query]);
|
||||
|
||||
const goToServices = async (project: {
|
||||
workspaceName: string;
|
||||
projectName: string;
|
||||
value: string;
|
||||
isFree: boolean;
|
||||
}) => {
|
||||
if (!project) {
|
||||
openAlertDialog({
|
||||
title: 'Please select a workspace and a project',
|
||||
payload:
|
||||
'You must select a workspace and a project before proceeding to create the run service',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (project.isFree) {
|
||||
openAlertDialog({
|
||||
title: 'The project must have a pro plan',
|
||||
payload: 'Creating run services is only availabel for pro projects',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({
|
||||
pathname: `/${project.value}/services`,
|
||||
// Keep the same query params that got us here
|
||||
query: router.query,
|
||||
});
|
||||
};
|
||||
|
||||
const projectsToDisplay = filter
|
||||
? projects.filter((project) =>
|
||||
project.projectName.toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: projects;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading workspaces and projects..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
New Run Service
|
||||
</Text>
|
||||
|
||||
<InfoCard
|
||||
title="Please select the workspace and the project where you want to create the service"
|
||||
disableCopy
|
||||
value=""
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{projectsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => goToServices(project)}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={project.projectName}
|
||||
secondary={`${project.workspaceName} / ${project.projectName}`}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
{index < projects.length - 1 && <Divider component="li" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
SelectWorkspaceAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="New Run Service">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@@ -48,7 +48,6 @@ export const mockApplication: Project = {
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
awsName: 'us-east-1',
|
||||
city: 'New York',
|
||||
|
||||
2525
dashboard/src/utils/__generated__/graphql.ts
generated
2525
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@ module.exports = {
|
||||
github: '#24292E;',
|
||||
brown: '#382D22',
|
||||
copper: '#DD792D',
|
||||
paper: '#171d26',
|
||||
divider: '#2f363d',
|
||||
},
|
||||
boxShadow: {
|
||||
outline: 'inset 0 0 0 2px rgba(0, 82, 205, 0.6)',
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4fe4a1696: return `refreshToken` immediately after signIn and signUp
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 612d75496: updated postgres and graphql documentation
|
||||
- 3b3e83a21: fix(docs): correct rendering of mermaid diagrams in dark mode
|
||||
- 765b398b2: added jit settings documentation
|
||||
- 30aae1557: minor fix to performance documentation
|
||||
- a3efc1d13: docs: added storage/antivirus documentation
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9aaa407d2: Fix messaging around Run's private beta
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 819e1e97d: update fqdn format for nhost run
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 960d815f6: added docs for Nhost Run
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
159
docs/docs/database/extensions.mdx
Normal file
159
docs/docs/database/extensions.mdx
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: 'Extensions'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
## postgis
|
||||
|
||||
PostGIS extends the capabilities of the PostgreSQL relational database by adding support storing, indexing and querying geographic data.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION postgis;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION postgis;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [Official website](https://postgis.net)
|
||||
|
||||
## pgvector
|
||||
|
||||
Open-source vector similarity search for Postgres. Store your vectors with the rest of your data. Supports:
|
||||
|
||||
* exact and approximate nearest neighbor search
|
||||
* L2 distance, inner product, and cosine distance
|
||||
* any language with a Postgres client
|
||||
|
||||
Plus ACID compliance, point-in-time recovery, JOINs, and all of the other great features of Postgres
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION vector;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION vector;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/pgvector/pgvector)
|
||||
|
||||
## pg_cron
|
||||
|
||||
pg_cron is a simple cron-based job scheduler for PostgreSQL (10 or higher) that runs inside the database as an extension. It uses the same syntax as regular cron, but it allows you to schedule PostgreSQL commands directly from the database. You can also use '[1-59] seconds' to schedule a job based on an interval.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_cron;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_cron;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/citusdata/pg_cron)
|
||||
|
||||
## hypopg
|
||||
|
||||
HypoPG is a PostgreSQL extension adding support for hypothetical indexes.
|
||||
|
||||
An hypothetical -- or virtual -- index is an index that doesn't really exists, and thus doesn't cost CPU, disk or any resource to create. They're useful to know if specific indexes can increase performance for problematic queries, since you can know if PostgreSQL will use these indexes or not without having to spend resources to create them.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION hypopg;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION hypopg;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/HypoPG/hypopg)
|
||||
* [Documentation](https://hypopg.readthedocs.io)
|
||||
|
||||
## timescaledb
|
||||
|
||||
TimescaleDB is an open-source database designed to make SQL scalable for time-series data. It is engineered up from PostgreSQL and packaged as a PostgreSQL extension, providing automatic partitioning across time and space (partitioning key), as well as full SQL support.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION timescaledb;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION timescaledb;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/timescale/timescaledb)
|
||||
* [Documentation](https://docs.timescale.com)
|
||||
* [Website](https://www.timescale.com)
|
||||
|
||||
## pg_stat_statements
|
||||
|
||||
The pg_stat_statements module provides a means for tracking planning and execution statistics of all SQL statements executed by a server.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_stat_statements;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_stat_statements;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [Documentation](https://www.postgresql.org/docs/14/pgstatstatements.html)
|
||||
89
docs/docs/database/performance.mdx
Normal file
89
docs/docs/database/performance.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: 'Performance'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
Ensuring a healthy and performant PostgreSQL service is crucial as it directly impacts the overall response time and stability of your backend. Since Postgres serves as the centerpiece of your backend, prioritize the optimization and maintenance of your Postgres service to achieve the desired performance and reliability.
|
||||
|
||||
In case your Postgres service is not meeting your performance expectations, you can explore the following options:
|
||||
|
||||
1. Consider upgrading your [dedicated compute](/platform/compute) resources to provide more processing power and memory to the Postgres server.
|
||||
|
||||
2. Fine-tune the configuration parameters of Postgres to optimize its performance. Adjust settings such as `shared_buffers`, `work_mem`, and `effective_cache_size` to better align with your workload and server resources.
|
||||
|
||||
3. Identify and analyze slow-performing queries using tools like query logs or query monitoring extensions. Optimize or rewrite these queries to improve their efficiency.
|
||||
|
||||
4. Evaluate the usage of indexes in your database. Identify queries that could benefit from additional indexes and strategically add them to improve query performance.
|
||||
|
||||
By implementing these steps, you can effectively address performance concerns and enhance the overall performance of your Postgres service.
|
||||
|
||||
## Upgrade to our latest postgres image
|
||||
|
||||
Before trying anything else, always upgrade to our latest postgres image first. You can find our availables images in the dashbhoard, under your database settings.
|
||||
|
||||
## Upgrading dedicated compute
|
||||
|
||||
Increasing CPU and memory is the simplest way to address performance issues. You can read more about compute resources [here](/platform/compute).
|
||||
|
||||
## Fine-tune configuration parameters
|
||||
|
||||
When optimizing your Postgres setup, you can consider adjusting various Postgres settings. You can find a list of these parameters [here](/database/settings). Keep in mind that the optimal values for these parameters will depend on factors such as available resources, workload, and data distribution.
|
||||
|
||||
To help you get started, you can use [pgtune](https://pgtune.leopard.in.ua) as a reference tool. Pgtune can generate recommended configuration settings based on your system specifications. By providing information about your system, it can suggest parameter values that may be a good starting point for optimization.
|
||||
|
||||
However, it's important to note that the generated settings from pgtune are not guaranteed to be the best for your specific environment. It's always recommended to review and customize the suggested settings based on your particular requirements, performance testing, and ongoing monitoring of your Postgres database.
|
||||
|
||||
## Identifying slow queries
|
||||
|
||||
Monitoring slow queries is a highly effective method for tackling performance issues. Several tools leverage [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html), a PostgreSQL extension, to provide constant monitoring. You can employ these tools to identify and address slow queries in real-time.
|
||||
|
||||
### pghero
|
||||
|
||||
[PgHero](https://github.com/ankane/pghero) is one of such tools you can use to idenfity and address slow queries. You can easily run pghero alongside your postgres with [Nhost Run](/run):
|
||||
|
||||
1. First, make sure the extension [pg_stat_statements](/database/extensions#pg_stat_statements) is enabled.
|
||||
|
||||
2. Click on this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoicGdoZXJvIiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vYW5rYW5lL3BnaGVybzpsYXRlc3QifSwiY29tbWFuZCI6W10sInJlc291cmNlcyI6eyJjb21wdXRlIjp7ImNwdSI6MTI1LCJtZW1vcnkiOjI1Nn0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbeyJuYW1lIjoiREFUQUJBU0VfVVJMIiwidmFsdWUiOiJwb3N0Z3JlczovL3Bvc3RncmVzOltQQVNTV09SRF1AcG9zdGdyZXMtc2VydmljZTo1NDMyL1tTVUJET01BSU5dP3NzbG1vZGU9ZGlzYWJsZSJ9LHsibmFtZSI6IlBHSEVST19VU0VSTkFNRSIsInZhbHVlIjoiW1VTRVJdIn0seyJuYW1lIjoiUEdIRVJPX1BBU1NXT1JEIiwidmFsdWUiOiJbUEFTU1dPUkRdIn1dLCJwb3J0cyI6W3sicG9ydCI6ODA4MCwidHlwZSI6Imh0dHAiLCJwdWJsaXNoIjp0cnVlfV19)
|
||||
|
||||
3. Select your project:
|
||||

|
||||
|
||||
4. Replace the placeholders with your postgres password, subdomain and a user and password to protect your pghero service. Finally, click on create.
|
||||

|
||||
|
||||
5. After confirming the service, copy the URL:
|
||||

|
||||
|
||||
6. Finally, you can open the link you just copied to access pghero:
|
||||
|
||||

|
||||
|
||||
|
||||
:::info
|
||||
When you create a new service, it can take a few minutes for the DNS (Domain Name System) to propagate. If your browser displays an error stating that it couldn't find the server or website, simply wait for a couple of minutes and then try again.
|
||||
:::
|
||||
|
||||
After successfully setting up pghero, it will begin displaying slow queries, suggesting index proposals, and offering other valuable information. Utilize this data to enhance your service's performance.
|
||||
|
||||
## Adding indexes
|
||||
|
||||
Indexes can significantly enhance the speed of data retrieval. However, it's essential to be aware that they introduce additional overhead during mutations. Therefore, understanding your workload is crucial before opting to add an index.
|
||||
|
||||
There are tools you can use to help analyze your workload and detect missing indexes.
|
||||
|
||||
### pghero
|
||||
|
||||
[PgHero](https://github.com/ankane/pghero), in addition to help with slow queries, can also help finding missing and duplicate indexes. See previous section on how to deploy pghero with [Nhost Run](/run).
|
||||
|
||||
### dexter
|
||||
|
||||
[Dexter](https://github.com/ankane/dexter) can leverage both [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html) and [hypopg](https://hypopg.readthedocs.io/en/rel1_stable/) to find and evaluate indexes. You can run dexter directly from your machine:
|
||||
|
||||
1. Enable [hypopg](/database/extensions#hypopg)
|
||||
2. Execute the command `docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements`
|
||||
|
||||
```
|
||||
$ docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements
|
||||
Processing 1631 new query fingerprints
|
||||
No new indexes found
|
||||
```
|
||||
84
docs/docs/database/settings.mdx
Normal file
84
docs/docs/database/settings.mdx
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: 'Settings'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Below you can find the official schema (cue) and an example to configure your postgres database:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema">
|
||||
|
||||
```cue
|
||||
#Postgres: {
|
||||
version: string | *"14.6-20230705-1"
|
||||
|
||||
// Resources for the service, optional
|
||||
resources?: #Resources & {
|
||||
replicas: 1
|
||||
}
|
||||
|
||||
// postgres settings of the same name in camelCase, optional
|
||||
settings?: {
|
||||
jit: "off" | "on" | *"on"
|
||||
maxConnections: int32 | *100
|
||||
sharedBuffers: string | *"128MB"
|
||||
effectiveCacheSize: string | *"4GB"
|
||||
maintenanceWorkMem: string | *"64MB"
|
||||
checkpointCompletionTarget: number | *0.9
|
||||
walBuffers: int32 | *-1
|
||||
defaultStatisticsTarget: int32 | *100
|
||||
randomPageCost: number | *4.0
|
||||
effectiveIOConcurrency: int32 | *1
|
||||
workMem: string | *"4MB"
|
||||
hugePages: string | *"try"
|
||||
minWalSize: string | *"80MB"
|
||||
maxWalSize: string | *"1GB"
|
||||
maxWorkerProcesses: int32 | *8
|
||||
maxParallelWorkersPerGather: int32 | *2
|
||||
maxParallelWorkers: int32 | *8
|
||||
maxParallelMaintenanceWorkers: int32 | *2
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml" default>
|
||||
|
||||
```toml
|
||||
[postgres]
|
||||
version = '14.6-20230925-1'
|
||||
|
||||
[postgres.resources.compute]
|
||||
cpu = 1000
|
||||
memory = 2048
|
||||
|
||||
[postgres.settings]
|
||||
jit = "off"
|
||||
maxConnections = 100
|
||||
sharedBuffers = '256MB'
|
||||
effectiveCacheSize = '768MB'
|
||||
maintenanceWorkMem = '64MB'
|
||||
checkpointCompletionTarget = 0.9
|
||||
walBuffers = -1
|
||||
defaultStatisticsTarget = 100
|
||||
randomPageCost = 1.1
|
||||
effectiveIOConcurrency = 200
|
||||
workMem = '1310kB'
|
||||
hugePages = 'off'
|
||||
minWalSize = '80MB'
|
||||
maxWalSize = '1GB'
|
||||
maxWorkerProcesses = 8
|
||||
maxParallelWorkersPerGather = 2
|
||||
maxParallelWorkers = 8
|
||||
maxParallelMaintenanceWorkers = 2
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
:::info
|
||||
At the time of writing this document postgres settings are only supported via the [configuration file](https://nhost.io/blog/config).
|
||||
:::
|
||||
@@ -144,6 +144,14 @@ One of the most common permission requirements is that authenticated users shoul
|
||||
1. Select the **columns** you want the user to be able to read. In our case, we'll allow the user to read all columns.
|
||||
1. Click **Save**.
|
||||
|
||||
## Known issues
|
||||
|
||||
### Permissions are slow
|
||||
|
||||
In certain situations, permission checks can cause significant delays. One way to identify this issue is by comparing the execution time of a GraphQL query when performed as an admin versus as a regular user. To resolve such cases, disabling the Just-in-Time (JIT) compilation in [Postgres](/database/settings) can be beneficial.
|
||||
|
||||
[Github issue](https://github.com/hasura/graphql-engine/issues/3672)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Hasura has more in-depth documentation related to permissions that you can learn from:
|
||||
|
||||
174
docs/docs/graphql/settings.mdx
Normal file
174
docs/docs/graphql/settings.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: 'Settings'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Below you can find the official schema (cue) and an example to configure your graphql service:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema">
|
||||
|
||||
```cue
|
||||
// Configuration for hasura service
|
||||
#Hasura: {
|
||||
// Version of hasura, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/hasura/graphql-engine/tags
|
||||
version: string | *"v2.33.4-ce"
|
||||
|
||||
// JWT Secrets configuration
|
||||
jwtSecrets: [#JWTSecret]
|
||||
|
||||
// Admin secret
|
||||
adminSecret: string
|
||||
|
||||
// Webhook secret
|
||||
webhookSecret: string
|
||||
|
||||
// Configuration for hasura services
|
||||
// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
|
||||
settings: {
|
||||
// HASURA_GRAPHQL_CORS_DOMAIN
|
||||
corsDomain: [...#Url] | *["*"]
|
||||
// HASURA_GRAPHQL_DEV_MODE
|
||||
devMode: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_ALLOWLIST
|
||||
enableAllowList: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLE_CONSOLE
|
||||
enableConsole: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
|
||||
enableRemoteSchemaPermissions: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLED_APIS
|
||||
enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
|
||||
|
||||
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
|
||||
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
|
||||
}
|
||||
|
||||
logs: {
|
||||
// HASURA_GRAPHQL_LOG_LEVEL
|
||||
level: "debug" | "info" | "error" | *"warn"
|
||||
}
|
||||
|
||||
events: {
|
||||
// HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
|
||||
httpPoolSize: uint32 & >=1 & <=100 | *100
|
||||
}
|
||||
|
||||
// Resources for the service
|
||||
resources?: #Resources
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml" default>
|
||||
|
||||
```toml
|
||||
[hasura]
|
||||
version = ''
|
||||
adminSecret = 'adminsecret'
|
||||
webhookSecret = 'webhooksecret'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'secret'
|
||||
|
||||
[hasura.settings]
|
||||
corsDomain = ['*']
|
||||
devMode = false
|
||||
enableAllowList = true
|
||||
enableConsole = true
|
||||
enableRemoteSchemaPermissions = true
|
||||
enabledAPIs = ['metadata']
|
||||
liveQueriesMultiplexedRefetchInterval = 1000
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
[hasura.events]
|
||||
httpPoolSize = 10
|
||||
|
||||
[hasura.resources]
|
||||
replicas = 1
|
||||
|
||||
[hasura.resources.compute]
|
||||
cpu = 500
|
||||
memory = 1024
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### JWT Secret
|
||||
|
||||
All formats supported by [hasura](https://hasura.io/docs/latest/auth/authentication/jwt/) should be supported:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema" default>
|
||||
|
||||
```cue
|
||||
#JWTSecret:
|
||||
({
|
||||
type: "HS384" | "HS512" | "RS256" | "RS384" | "RS512" | "Ed25519" | *"HS256"
|
||||
key: string
|
||||
} |
|
||||
{
|
||||
jwk_url: #Url | *null
|
||||
}) &
|
||||
{
|
||||
claims_format?: "stringified_json" | *"json"
|
||||
audience?: string
|
||||
issuer?: string
|
||||
allowed_skew?: uint32
|
||||
header?: string
|
||||
} & {
|
||||
claims_map?: [...#ClaimMap]
|
||||
|
||||
} &
|
||||
({
|
||||
claims_namespace: string | *"https://hasura.io/jwt/claims"
|
||||
} |
|
||||
{
|
||||
claims_namespace_path: string
|
||||
} | *{})
|
||||
|
||||
#ClaimMap: {
|
||||
claim: string
|
||||
{
|
||||
value: string
|
||||
} | {
|
||||
path: string
|
||||
default?: string
|
||||
}
|
||||
} & {
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```toml
|
||||
# example 1
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'secret'
|
||||
|
||||
# example 2
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = 'https:/....'
|
||||
|
||||
# example 3
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = "https://......"
|
||||
issuer = "https://my-auth-server.com"
|
||||
|
||||
[[hasura.jwtSecrets.claims_map]]
|
||||
claim = "x-some-claim"
|
||||
value = "some-value"
|
||||
|
||||
[[hasura.jwtSecrets.claims_map]]
|
||||
claim = "x-other-claim"
|
||||
path = "$.user.claim.id"
|
||||
default = "default-value"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -28,6 +28,7 @@ Learn more about the product and features of Nhost.
|
||||
- [Authentication](/authentication)
|
||||
- [Storage](/storage)
|
||||
- [Serverless Functions](/serverless-functions)
|
||||
- [Run](/run)
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
37
docs/docs/run/2.getting_started.mdx
Normal file
37
docs/docs/run/2.getting_started.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Getting Started
|
||||
sidebar_label: Getting Started
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
To start with Nhost Run, you will need to create an Nhost project first. Then you can click on `Run` in the sidebar:
|
||||
|
||||

|
||||
|
||||
Then on `New Service`:
|
||||
|
||||

|
||||
|
||||
Now you can fill your [service configuration](/run/configuration):
|
||||
|
||||

|
||||
|
||||
As you configure the `Ports` section you can take note of the generated URL. You can find more information about this section under [Networking](/run/networking).
|
||||
|
||||

|
||||
|
||||
Once you are done configuring your service you can click on `Create`:
|
||||
|
||||

|
||||
|
||||
Now wait for the service to finish updating:
|
||||
|
||||

|
||||
|
||||
Finally you can visit the URL you copied before:
|
||||
|
||||

|
||||
|
||||
And profit!
|
||||
59
docs/docs/run/3_configuration.mdx
Normal file
59
docs/docs/run/3_configuration.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Configuration
|
||||
sidebar_label: Configuration
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
To configure a service you can use either the dashboard or a TOML configuration file with the following format:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```
|
||||
name="mynodeapp"
|
||||
|
||||
[image]
|
||||
image="registry.eu-central-1.nhost.run/b719c4a4-6fb2-4feb-8220-c7f01ef5bfe8:0.1.1"
|
||||
|
||||
[[environment]]
|
||||
name="ENV"
|
||||
value="prod"
|
||||
|
||||
[[ports]]
|
||||
type="http"
|
||||
port=3000
|
||||
publish=true
|
||||
|
||||
[resources]
|
||||
replicas=1
|
||||
|
||||
[resources.compute]
|
||||
cpu=62
|
||||
memory=128
|
||||
|
||||
[[resources.storage]]
|
||||
name="data"
|
||||
path="/var/lib/data"
|
||||
capacity=1
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
::::info
|
||||
Head to [CLI & CI deployments](/run/ci) for more details on how to deploy using a configuration file
|
||||
::::
|
||||
|
||||
The `name` of the service is used as an identifier and to generate URLs when exposing the service to the Internet. You can use any container image publicly available or you can push your own to the [Nhost registry](/run/registry).
|
||||
|
||||
All environment variables set here are exclusive to this service and will not be shared with other services or with the Nhost stack. If you are using a configuration file secrets are supported.
|
||||
|
||||
For more details about the `Ports` section head to [networking](/run/networking). You can also head to [resources](/run/resources) for more information about replicas, compute, and storage.
|
||||
119
docs/docs/run/4_networking.mdx
Normal file
119
docs/docs/run/4_networking.mdx
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Networking
|
||||
sidebar_label: Networking
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
|
||||
Nhost Run services and the main Nhost stack share the same network, facilitating direct and efficient communication. This network connectivity offers low latency, high throughput, and eliminates egress costs.
|
||||
## Connecting to the Nhost stack
|
||||
|
||||
To connect your service to the Nhost stack, use the following information:
|
||||
|
||||
- postgres: `postgres://postgres:<password>@postgres-service:5432/<subdomain>?sslmode=disable`
|
||||
- hasura: base URL is `http://hasura-service:8080`
|
||||
- hasura-auth: base URL is `http://hasura-auth-service:4000`
|
||||
- hasura-storage: base URL is `http://hasura-storage-service:5000`
|
||||
|
||||
## Connecting to your service internally
|
||||
|
||||
To connect to your own service internally from another service, follow these steps:
|
||||
|
||||
1. Expose the desired port(s).
|
||||
|
||||
Example for Redis service:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```
|
||||
[[ports]]
|
||||
type = "tcp"
|
||||
port = 6379
|
||||
publish = false
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
2. Once the port is exposed, you can connect to the service using its name and the corresponding port.
|
||||
|
||||
Example: `redis://user:password@redis:6379`
|
||||
|
||||
If needed, you can open internally more than one port by repeating the block for each one of them:
|
||||
|
||||
Example for a service exposing the ports tcp/3000 and udp/4000:
|
||||
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```
|
||||
[[ports]]
|
||||
type = "tcp"
|
||||
port = 3000
|
||||
publish = false
|
||||
|
||||
[[ports]]
|
||||
type = "udp"
|
||||
port = 4000
|
||||
publish = false
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Exposing Your Service to the Internet
|
||||
|
||||
To expose your service to the internet, follow these steps:
|
||||
|
||||
1. Update your configuration with the relevant port information:
|
||||
|
||||
Example for a nodejs service exposing an API on port 3000:
|
||||
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```
|
||||
[[ports]]
|
||||
type = "http"
|
||||
port = 3000
|
||||
publish = true
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::info
|
||||
Currently, only services of type `http` can be exposed to the internet.
|
||||
:::
|
||||
|
||||
2. Once the service of type `http` is published, you can connect to it using a URL with the following format:
|
||||
|
||||
`https://<run_service_subdomain>-<port>.svc.<region>.nhost.run`
|
||||
|
||||
For example:
|
||||
|
||||
`https://zlbmqjfczuwqvsquujno-3000.svc.eu-central-1.nhost.run`
|
||||
106
docs/docs/run/5_resources.mdx
Normal file
106
docs/docs/run/5_resources.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Resources
|
||||
sidebar_label: Resources
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
|
||||
## Compute
|
||||
|
||||
You can configure compute resources for your service like this:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```
|
||||
[resources.compute]
|
||||
cpu = 62
|
||||
memory = 128
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
If you are configuring the resources via the configuration file instead of using the dashboard, keep in mind that cpu and memory allocation need to follow the following rules:
|
||||
|
||||
* The value of the `memory` parameter must be exactly 2,048 times the value of the cpu parameter.
|
||||
* The value of the memory parameter must be within the range of 128 to 7,168 (inclusive).
|
||||
|
||||
Don't forget you can use `nhost run config-validate` command to verify your values are correct.
|
||||
|
||||
## Storage
|
||||
|
||||
By default a container's disk is ephemeral so data isn't persisted across reboots. If your service needs persistent storage you can attach one or more SSD disks by adding the following configuration:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
``` toml
|
||||
[[resources.storage]]
|
||||
name="database"
|
||||
path="/var/lib/db"
|
||||
capacity=10
|
||||
|
||||
|
||||
[[resources.storage]]
|
||||
name="data"
|
||||
path="/mnt/data"
|
||||
capacity=1
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
With the above configuration, two disks will be provided. The first disk named `database` will have a capacity of 10 GiB and will be mounted at `/var/lib/db`. The second disk named `data` will have a capacity of 1 GiB and will be mounted at ``/mnt/data`.`
|
||||
|
||||
:::warning
|
||||
Please note that renaming a disk will result in the destruction of the old disk and the creation of a new one, potentially leading to data loss.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
Volume capacity can not be decreased. Once a volume with a given size has been created, its capacity can only by expanded.
|
||||
:::
|
||||
|
||||
It's important to note that disks are unique to each service and cannot be shared across multiple services.
|
||||
|
||||
|
||||
## Pausing a service
|
||||
|
||||
To pause a service, simply set its number of replicas to `0`:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```toml
|
||||
[resources]
|
||||
replicas = 0
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
When you pause a service, it will cease running while keeping any associated disk and registry intact.
|
||||
|
||||
:::info
|
||||
While the service is paused, computing costs will not be charged. However, storage costs will continue to apply.
|
||||
:::
|
||||
96
docs/docs/run/6_registry.mdx
Normal file
96
docs/docs/run/6_registry.mdx
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: Registry
|
||||
sidebar_label: Registry
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
## Creating a private repository for your image
|
||||
|
||||
We provide a private image registry you can push images to with each service. To make use of it you can start by creating a service and configuring it:
|
||||
|
||||

|
||||
|
||||
Note that we are leaving the Image empty, this will auto-populate the service with the provisioned registry. We are also setting `Replicas` to 0 to avoid starting the service and incurring costs before we have pushed the image.
|
||||
|
||||
Now you can click on Create:
|
||||
|
||||

|
||||
|
||||
Now you can click on the newly created service:
|
||||
|
||||

|
||||
|
||||
And copy the image:
|
||||
|
||||

|
||||
|
||||
## Configuring docker to use Nhost's registry
|
||||
|
||||
The CLI can configure docker automatically to be able to push and pull images from the docker registry. To do so you need to run the following command:
|
||||
|
||||
```
|
||||
$ nhost docker-credentials configure
|
||||
Installing credentials helper for docker in /usr/local/bin/docker-credential-nhost-login
|
||||
I need root privileges to install the file. Please, enter your password.
|
||||
Password:
|
||||
- I am about to configure docker to authenticate with Nhost's registry. This will modify your docker config file on /Users/dbarroso/.docker/config.json. Should I continue? [y/N] y
|
||||
````
|
||||
|
||||
After you have configured the credentials helper with the command above docker should be able to interact with your images normally:
|
||||
|
||||
|
||||
```
|
||||
$ docker buildx build \
|
||||
--push \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.eu-central-1.nhost.run/f02bb536-f785-4732-9eb8-d1d3664d7949:123 \
|
||||
.
|
||||
[+] Building 8.7s (11/11) FINISHED docker-container:focused_dirac
|
||||
=> [internal] load .dockerignore 0.0s
|
||||
=> => transferring context: 2B 0.0s
|
||||
=> [internal] load build definition from Dockerfile 0.0s
|
||||
=> => transferring dockerfile: 96B 0.0s
|
||||
=> [linux/arm64 internal] load metadata for docker.io/library/node:18-slim 6.4s
|
||||
=> [linux/amd64 internal] load metadata for docker.io/library/node:18-slim 6.4s
|
||||
=> [linux/arm64 1/2] FROM docker.io/library/node:18-slim@sha256:bfa807593c4e904c9dbdeec45a266d38040804e498c714bddf59734a1ed34730 0.0s
|
||||
=> => resolve docker.io/library/node:18-slim@sha256:bfa807593c4e904c9dbdeec45a266d38040804e498c714bddf59734a1ed34730 0.0s
|
||||
=> [internal] load build context 0.0s
|
||||
=> => transferring context: 28B 0.0s
|
||||
=> [linux/amd64 1/2] FROM docker.io/library/node:18-slim@sha256:bfa807593c4e904c9dbdeec45a266d38040804e498c714bddf59734a1ed34730 0.0s
|
||||
=> => resolve docker.io/library/node:18-slim@sha256:bfa807593c4e904c9dbdeec45a266d38040804e498c714bddf59734a1ed34730 0.0s
|
||||
=> CACHED [linux/arm64 2/2] ADD app.js app.js 0.0s
|
||||
=> CACHED [linux/amd64 2/2] ADD app.js app.js 0.0s
|
||||
=> exporting to image 2.2s
|
||||
=> => exporting layers 0.0s
|
||||
=> => exporting manifest sha256:b38199ab8a25d3c765cd763be8af6ea6b3542455f2e7ad37f92924538dbc2af7 0.0s
|
||||
=> => exporting config sha256:d66a5274b034a3b5698044a03bbec53dccc9e4f5f774701c59604fa17c8f0ff3 0.0s
|
||||
=> => exporting attestation manifest sha256:c5487067b094ab1d7a81a216595a0f773c55ceec95319e844cded94247e4d341 0.0s
|
||||
=> => exporting manifest sha256:f1c56d22755cae4d73ee432f11d248ca10ae4b89be3d56f0ffe95e5d7ce10542 0.0s
|
||||
=> => exporting config sha256:a532f91c5f9899a150b9c981933fa3b163879dcb5ceb48ebc132f72b564a5878 0.0s
|
||||
=> => exporting attestation manifest sha256:9e1a0207de26c335df4cee8398ada86f7b6a68ebb86e39550fe862416a10df84 0.0s
|
||||
=> => exporting manifest list sha256:f0d8521804f16280642e99c8c25bbd66f659a9a9bd7bb72cf0726dd98d9bfb00 0.0s
|
||||
=> => pushing layers 1.2s
|
||||
=> => pushing manifest for registry.eu-central-1.nhost.run/f02bb536-f785-4732-9eb8-d1d3664d7949:123@sha256:f0d8521804f16280642e99c8c25bbd66f659a9a9bd7bb72cf0726dd98d9bfb00 1.0s
|
||||
=> [auth] sharing credentials for registry.eu-central-1.nhost.run 0.0s
|
||||
|
||||
|
||||
```
|
||||
:::info
|
||||
The credentials helper will authenticate requests with the logged in user, so don't forget to authenticate with your user before trying to push or pull images. You can log in by running the command `nhost login`.
|
||||
:::
|
||||
|
||||
## Updating the image in the service configuration
|
||||
|
||||
After you have pushed your image you can click on your service again and update the configuration:
|
||||
|
||||

|
||||
|
||||
Notice we added the tag `:123` to the image that was already pre-populated and that we increased replicas to `1` to unpause the service. Don't forget to copy the URL where we are exposing the service. Now you can click on update:
|
||||
|
||||

|
||||
|
||||
Wait a few seconds until the project is done updating the new service and visit the URL we copied before:
|
||||
|
||||

|
||||
53
docs/docs/run/7_ci.mdx
Normal file
53
docs/docs/run/7_ci.mdx
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: CLI & CI Deployments
|
||||
sidebar_label: CLI & CI Deployments
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
## Deploying with the CLI
|
||||
|
||||
Below you can find a simple script to deploy a Nhost Run service using the CLI. This script can be leveraged on any CI or environment as long as you can install the Nhost CLI and have access to docker buildx.
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
SERVICE_ID="2503b290-249c-42f5-b89e-fd9a98980e22"
|
||||
IMAGE="registry.eu-central-1.nhost.run/2503b290-249c-42f5-b89e-fd9a98980e22"
|
||||
PAT="this-is-my-pat"
|
||||
VERSION="1.0.0"
|
||||
CONFIGURATION_FILE="nhost-service.toml"
|
||||
|
||||
# this only needs to be done once in each environment
|
||||
# but usually CIs start with a clean environment each time
|
||||
#
|
||||
# you can also login with your regular email/password
|
||||
# credentials if you prefer
|
||||
nhost login --pat $PAT
|
||||
|
||||
# this only needs to be done once in each environment
|
||||
# but usually CIs start with a clean environment each time
|
||||
nhost docker-credentials configure --no-interactive
|
||||
|
||||
docker buildx build \
|
||||
--push \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t $IMAGE:$VERSION \
|
||||
.
|
||||
|
||||
nhost run config-deploy \
|
||||
--config $CONFIGURATION \
|
||||
--service-id $SERVICE_ID
|
||||
```
|
||||
|
||||
::::info
|
||||
You can create a PAT by heading to the dashboard -> Account Settings -> Create Personal Access Token
|
||||
::::
|
||||
|
||||
## Deploy from Github Actions
|
||||
|
||||
If you prefer to deploy from GitHub actions we support a few GitHub actions you can use to build your own workflows. We also have an already-made workflow you can leverage and there is a hello-world application you can check for a more thorough example and a step-by-step demo:
|
||||
|
||||
1. [Github actions](https://github.com/marketplace?type=actions&query=nhost+)
|
||||
2. [Workflows](https://github.com/nhost-actions/workflows#build-and-release-nhost-runyaml)
|
||||
3. [Hello World](https://github.com/nhost/nhost-run-hello-world)
|
||||
4
docs/docs/run/_category_.json
Normal file
4
docs/docs/run/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Run",
|
||||
"position": 9
|
||||
}
|
||||
49
docs/docs/run/index.mdx
Normal file
49
docs/docs/run/index.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Overview
|
||||
sidebar_label: Overview
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Nhost Run enables you to seamlessly incorporate your custom software within your project environment. With this feature, you can run various applications, including homegrown backend services, agents, extensions for your GraphQL API through remote schemas or actions, data-processing workloads, etc., all in close proximity to your database for low latency, speed, and efficiency.
|
||||
|
||||

|
||||
|
||||
:::info
|
||||
Currently Nhost Run is in public beta. If you find any bugs or if you have any feedback and suggestions, please reach out to us via [email](mailto:support@nhost.io), [GitHub](https://github.com/nhost/nhost/issues), or [Discord](https://discord.com/invite/9V7Qb2U)
|
||||
:::
|
||||
|
||||
## Use Cases
|
||||
|
||||
Nhost Run allows you to expand and truly customize your backend in multiple ways:
|
||||
|
||||
1. Homegrown Backend Services: Deploy and execute your custom backend services within your project environment.
|
||||
2. GraphQL API Extensions: Extend your GraphQL API functionalities by incorporating remote schemas or actions.
|
||||
3. Data-Processing Workloads: Execute data-processing tasks in close proximity to your database for enhanced efficiency.
|
||||
4. OSS and third-party software: Redis, memcache, datadog agents, mysql, mongodb... anything your application needs.
|
||||
|
||||
## Advantages
|
||||
|
||||
Nhost Run offers several key advantages for running workloads alongside your project:
|
||||
|
||||
1. **Minimal Latency**: By running workloads alongside your project environment, Nhost Run reduces latency between services. This means that the communication and data exchange between different components of your project can occur quickly and efficiently.
|
||||
2. **Improved Reliability**: Nhost Run eliminates the dependency on external internet connectivity. This increased reliability ensures that your workloads continue to function even in scenarios where internet access may be limited or disrupted.
|
||||
3. **No Egress Costs**: With Nhost Run, you won't incur additional egress costs for transferring data between your project and the cloud infrastructure. This cost-saving benefit allows you to manage your expenses more effectively.
|
||||
4. **Integrated operations**: Develop, build, manage and scale your own workloads the same way you can manage your Nhost Project.
|
||||
|
||||
By leveraging Nhost Run, you can optimize the performance, reliability, and cost-efficiency of your project by running workloads alongside it.
|
||||
|
||||
## Requirements
|
||||
|
||||
Nhost Run works with container images built for the **arm architecture**. Images can be pulled from the [Nhost's private registry](/run/registry) or from any other publicly available registry.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Some missing functionality we are currently working on and should be added soon:
|
||||
|
||||
1. Custom domains
|
||||
2. Run services with the CLI alongside your project
|
||||
3. Ability to connect services to repositories for automated building and deployment (currently this needs to be done via a third party CI, see [Deployment via CI](/run/ci) for more details).
|
||||
4. Expose TCP/UDP ports
|
||||
4
docs/docs/storage/_category_.json
Normal file
4
docs/docs/storage/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Storage",
|
||||
"position": 7
|
||||
}
|
||||
44
docs/docs/storage/av.mdx
Normal file
44
docs/docs/storage/av.mdx
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Antivirus
|
||||
sidebar_label: Antivirus
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Integration with [clamav](https://www.clamav.net) antivirus relies on an external [clamd](https://docs.clamav.net/manual/Usage/Scanning.html#clamd) service. When a file is uploaded `hasura-storage` will create the file metadata first and then check if the file is clean with `clamd` via its TCP socket. If the file is clean the rest of the process will continue as usual. If a virus is found details about the virus will be added to the `virus` table and the rest of the process will be aborted.
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
User ->> storage: upload file
|
||||
storage ->>clamav: check for virus
|
||||
alt virus found
|
||||
storage-->s3: abort upload
|
||||
storage->>graphql: insert row in virus table
|
||||
else virus not found
|
||||
storage->>s3: upload
|
||||
storage->>graphql: update metadata
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
To enable the antivirus you need to follow the next steps:
|
||||
|
||||
|
||||
1. Deploy using [Nhost Run](/run) a dedicated instance of `clamd` with this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoiY2xhbWF2IiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vbmhvc3QvY2xhbWF2OjAuMS4xIn0sImNvbW1hbmQiOltdLCJyZXNvdXJjZXMiOnsiY29tcHV0ZSI6eyJjcHUiOjEwMDAsIm1lbW9yeSI6MjA0OH0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbXSwicG9ydHMiOlt7InBvcnQiOiIzMzEwIiwidHlwZSI6InRjcCIsInB1Ymxpc2giOmZhbHNlfV19).
|
||||
2. Select the project:
|
||||

|
||||
3. Click on "Create":
|
||||

|
||||
4. Make sure you are running **at least** storage version 0.4.0 and enable the antivirus:
|
||||

|
||||
5. Wait for the service to update and try to upload a sample virus file like [eicar](https://www.eicar.org/download-anti-malware-testfile/)
|
||||

|
||||
6. If the setup is working the upload should fail
|
||||

|
||||
7. You can also head to hasura and verify entries were added to the `virus` table:
|
||||

|
||||
|
||||
That entry should have useful information about like the filename, the virus found and the user session. In addition, the information on that table can be used a source for events.
|
||||
57
docs/docs/storage/example_crm.mdx
Normal file
57
docs/docs/storage/example_crm.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "Example: CRM System"
|
||||
sidebar_label: "Example: CRM System"
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
|
||||
|
||||
Start with, you would have two tables:
|
||||
|
||||
1. `customers` - Customer data.
|
||||
2. `customer_files` - What file belongs to what customer
|
||||
|
||||
```text
|
||||
- customers
|
||||
- id
|
||||
- name
|
||||
- address
|
||||
|
||||
customer_files
|
||||
- id
|
||||
- customer_id (Foreign Key to `customers.id`)
|
||||
- file_id (Foreign Key to `storage.files.id`)
|
||||
```
|
||||
|
||||
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
|
||||
|
||||
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
customers {
|
||||
# customers table
|
||||
id
|
||||
name
|
||||
customer_files {
|
||||
# customer_files table
|
||||
id
|
||||
file {
|
||||
# storage.files table
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file upload process would be as follows:
|
||||
|
||||
1. Upload a file.
|
||||
2. Get the returned file id.
|
||||
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
|
||||
|
||||
This would allow you to upload and download files belonging to specific customers in your CRM system.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Storage'
|
||||
sidebar_position: 7
|
||||
title: 'Overview'
|
||||
image: /img/og/storage.png
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
@@ -195,57 +195,3 @@ Image Transformation works on both public and pre-signed URLs.
|
||||
```text
|
||||
https://[subdomain].storage.[region].nhost.run/v1/files/08e6fa32-0880-4d0e-a832-278198acb363?w=500
|
||||
```
|
||||
|
||||
## Example: CRM System
|
||||
|
||||
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
|
||||
|
||||
Start with, you would have two tables:
|
||||
|
||||
1. `customers` - Customer data.
|
||||
2. `customer_files` - What file belongs to what customer
|
||||
|
||||
```text
|
||||
- customers
|
||||
- id
|
||||
- name
|
||||
- address
|
||||
|
||||
customer_files
|
||||
- id
|
||||
- customer_id (Foreign Key to `customers.id`)
|
||||
- file_id (Foreign Key to `storage.files.id`)
|
||||
```
|
||||
|
||||
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
|
||||
|
||||
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
customers {
|
||||
# customers table
|
||||
id
|
||||
name
|
||||
customer_files {
|
||||
# customer_files table
|
||||
id
|
||||
file {
|
||||
# storage.files table
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file upload process would be as follows:
|
||||
|
||||
1. Upload a file.
|
||||
2. Get the returned file id.
|
||||
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
|
||||
|
||||
This would allow you to upload and download files belonging to specific customers in your CRM system.
|
||||
@@ -26,6 +26,10 @@ const config = {
|
||||
favicon: 'img/favicon.png',
|
||||
organizationName: 'nhost',
|
||||
projectName: 'docs',
|
||||
markdown: {
|
||||
mermaid: true
|
||||
},
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
scripts: [
|
||||
{ src: 'https://plausible.io/js/script.js', defer: true, 'data-domain': 'docs.nhost.io' }
|
||||
],
|
||||
@@ -44,7 +48,6 @@ const config = {
|
||||
routeBasePath: '/',
|
||||
breadcrumbs: false,
|
||||
sidebarPath: require.resolve('./sidebars.js'),
|
||||
remarkPlugins: [require('mdx-mermaid')],
|
||||
editUrl: 'https://github.com/nhost/nhost/edit/main/docs/'
|
||||
},
|
||||
theme: {
|
||||
@@ -177,6 +180,7 @@ const config = {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
defaultLanguage: 'javascript',
|
||||
additionalLanguages: ['cue', 'toml'],
|
||||
magicComments: [
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
@@ -19,12 +19,13 @@
|
||||
"@docusaurus/core": "2.4.1",
|
||||
"@docusaurus/plugin-sitemap": "2.4.1",
|
||||
"@docusaurus/preset-classic": "2.4.1",
|
||||
"@docusaurus/theme-mermaid": "2.4.1",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-image-zoom": "^0.1.1",
|
||||
"mdx-mermaid": "^1.3.2",
|
||||
"mermaid": "^9.0.0",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"unist-util-visit": "^2.0.0"
|
||||
|
||||
@@ -44,8 +44,27 @@ const sidebars = {
|
||||
}
|
||||
]
|
||||
},
|
||||
'storage',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Storage',
|
||||
items: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: 'storage'
|
||||
}
|
||||
]
|
||||
},
|
||||
'serverless-functions',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Run',
|
||||
items: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: 'run'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'CLI',
|
||||
|
||||
2
docs/src/theme/prism-include-languages.d.ts
vendored
Normal file
2
docs/src/theme/prism-include-languages.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import type * as PrismNamespace from 'prismjs';
|
||||
export default function prismIncludeLanguages(PrismObject: typeof PrismNamespace): void;
|
||||
20
docs/src/theme/prism-include-languages.js
Normal file
20
docs/src/theme/prism-include-languages.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/*global globalThis*/
|
||||
import siteConfig from '@generated/docusaurus.config'
|
||||
export default function prismIncludeLanguages(PrismObject) {
|
||||
const {
|
||||
themeConfig: { prism }
|
||||
} = siteConfig
|
||||
const { additionalLanguages } = prism
|
||||
// Prism components work on the Prism instance on the window, while prism-
|
||||
// react-renderer uses its own Prism instance. We temporarily mount the
|
||||
// instance onto window, import components to enhance it, then remove it to
|
||||
// avoid polluting global namespace.
|
||||
// You can mutate PrismObject: registering plugins, deleting languages... As
|
||||
// long as you don't re-assign it
|
||||
globalThis.Prism = PrismObject
|
||||
additionalLanguages.forEach((lang) => {
|
||||
// eslint-disable-next-line global-require, import/no-dynamic-require
|
||||
require(`prismjs/components/prism-${lang}`)
|
||||
})
|
||||
delete globalThis.Prism
|
||||
}
|
||||
BIN
docs/static/img/database/performance/pghero_01.png
vendored
Normal file
BIN
docs/static/img/database/performance/pghero_01.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/static/img/database/performance/pghero_02.png
vendored
Normal file
BIN
docs/static/img/database/performance/pghero_02.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/static/img/database/performance/pghero_03.png
vendored
Normal file
BIN
docs/static/img/database/performance/pghero_03.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user