Compare commits
237 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9190dd726d | ||
|
|
ae093283d0 | ||
|
|
875327fbea | ||
|
|
3d5c34f4ce | ||
|
|
58c2a20532 | ||
|
|
7e37570587 | ||
|
|
87d225a840 | ||
|
|
7b0de27c80 | ||
|
|
564fc76195 | ||
|
|
2ed4f40c12 | ||
|
|
d67a023e21 | ||
|
|
c99d117d1c | ||
|
|
a497a6ba0a | ||
|
|
160cd08cc7 | ||
|
|
120151c40c | ||
|
|
9dc16f29b3 | ||
|
|
964fc5644a | ||
|
|
2f907fc68f | ||
|
|
fe6cadc2cd | ||
|
|
338c8e5a80 | ||
|
|
e6f3a1a39d | ||
|
|
a168faeb69 | ||
|
|
b1628c59b5 | ||
|
|
32a2f5db9a | ||
|
|
818a48f74d | ||
|
|
bed377d05f | ||
|
|
709a616cfa | ||
|
|
860e2d877c | ||
|
|
5c6b2f88b9 | ||
|
|
f151a0e872 | ||
|
|
4a84bbb410 | ||
|
|
fa3a50e323 | ||
|
|
398152358c | ||
|
|
34ae9046f3 | ||
|
|
a478689587 | ||
|
|
9dbc0607dc | ||
|
|
7455efdd53 | ||
|
|
d0aff6141f | ||
|
|
aed0c4f82a | ||
|
|
74d4276c1a | ||
|
|
1e98130aa1 | ||
|
|
52e9b510da | ||
|
|
ece197eb6b | ||
|
|
d14e112bff | ||
|
|
83884f04a5 | ||
|
|
977de21e86 | ||
|
|
462a60a8f8 | ||
|
|
9aa4371ef4 | ||
|
|
f0feddd83f | ||
|
|
0748cab125 | ||
|
|
27885491ee | ||
|
|
a36bdbf907 | ||
|
|
d3e8bb94ae | ||
|
|
645595ee43 | ||
|
|
4d82bc5609 | ||
|
|
fdf1e555d8 | ||
|
|
90c694cbba | ||
|
|
3262fa7b37 | ||
|
|
ab43fe567f | ||
|
|
b4c10f9f8a | ||
|
|
f4c6e7cfab | ||
|
|
72d1e94cb3 | ||
|
|
82d221a48d | ||
|
|
3fe46771b9 | ||
|
|
a1c487aa21 | ||
|
|
cf455608e2 | ||
|
|
5dac12dd41 | ||
|
|
2389b46e0d | ||
|
|
6fe2d22d0e | ||
|
|
0b439149e4 | ||
|
|
a9d7da8af7 | ||
|
|
3ecc21a45e | ||
|
|
aa19e85cdc | ||
|
|
26c650227d | ||
|
|
face99ccde | ||
|
|
49bcc525ad | ||
|
|
533563c893 | ||
|
|
cfe527307e | ||
|
|
1e36c6706d | ||
|
|
6e40b114fc | ||
|
|
77acf1385d | ||
|
|
cec7edd2d5 | ||
|
|
9dbbdb3121 | ||
|
|
79d2602648 | ||
|
|
b0363a4f4c | ||
|
|
17045b2018 | ||
|
|
c49cc11862 | ||
|
|
c83fe7d776 | ||
|
|
235b4c7405 | ||
|
|
c2c0fbd33a | ||
|
|
300e3f49e0 | ||
|
|
a95a77886b | ||
|
|
1f3f683202 | ||
|
|
4c67fd23c4 | ||
|
|
93d8d71e34 | ||
|
|
47bda15ff2 | ||
|
|
4563488b5d | ||
|
|
8fd35f3fea | ||
|
|
9c61c69a7b | ||
|
|
030ad4621e | ||
|
|
ee0b9b8edc | ||
|
|
c6fa8da6df | ||
|
|
dd9dedc226 | ||
|
|
5638a91240 | ||
|
|
cdefbdebee | ||
|
|
923abd3655 | ||
|
|
ef28540f9a | ||
|
|
d54e4cdd4e | ||
|
|
4a00963602 | ||
|
|
7ea9b890c8 | ||
|
|
f866120a65 | ||
|
|
472559276c | ||
|
|
2cdb13b3ef | ||
|
|
a41124c5e0 | ||
|
|
6ecffa81ae | ||
|
|
ea7b102c07 | ||
|
|
e9daf92830 | ||
|
|
9e4ad76e7f | ||
|
|
0fd65db563 | ||
|
|
146fbb84b9 | ||
|
|
b51c18fedb | ||
|
|
a5305e6b56 | ||
|
|
aa88ef2e5c | ||
|
|
ee6b3c9ac8 | ||
|
|
79fd86acc5 | ||
|
|
c2cbeddcb8 | ||
|
|
62b2de59d4 | ||
|
|
2a760593db | ||
|
|
9288873ce8 | ||
|
|
47014be8e3 | ||
|
|
49719f7a84 | ||
|
|
b3b64a3b74 | ||
|
|
3a56c12df4 | ||
|
|
5b15a4f235 | ||
|
|
83303017c3 | ||
|
|
e0739a5883 | ||
|
|
0a5a841cc8 | ||
|
|
3309835f06 | ||
|
|
32b221f944 | ||
|
|
e8a99badb8 | ||
|
|
1ea6e01963 | ||
|
|
958dec5dfe | ||
|
|
09257fbfb2 | ||
|
|
61e3497a13 | ||
|
|
a7b4e5606d | ||
|
|
34d77c9db1 | ||
|
|
4f1efd28a6 | ||
|
|
07a45fde0e | ||
|
|
9d0380eef3 | ||
|
|
ce3ec36b0a | ||
|
|
b62a9d19b5 | ||
|
|
c1472079c5 | ||
|
|
dd36971798 | ||
|
|
6199c1c555 | ||
|
|
f41fdc12af | ||
|
|
fc419ffa4d | ||
|
|
b7c102e876 | ||
|
|
873fc36e61 | ||
|
|
29743f0b71 | ||
|
|
d904ca2bbf | ||
|
|
80b22724de | ||
|
|
80e49f4459 | ||
|
|
b3d5ead508 | ||
|
|
77dcb8c964 | ||
|
|
3488da9dfd | ||
|
|
0e68a1fdfd | ||
|
|
8797b2bd17 | ||
|
|
5ef0b31573 | ||
|
|
86e5e0fb50 | ||
|
|
c2d589dd29 | ||
|
|
4b807d8134 | ||
|
|
ccdabb707f | ||
|
|
364bc87fd3 | ||
|
|
cc02902cbb | ||
|
|
0e838b9406 | ||
|
|
37ebf7d8e2 | ||
|
|
e23af24bdd | ||
|
|
90eb53cf19 | ||
|
|
7e516d7630 | ||
|
|
0861e41e70 | ||
|
|
057e7e2572 | ||
|
|
5a4e237a29 | ||
|
|
c7501c70ae | ||
|
|
6a45c1abad | ||
|
|
660d339e14 | ||
|
|
3dca08595d | ||
|
|
7c501c4e4f | ||
|
|
b9316bb668 | ||
|
|
5e1d5b737c | ||
|
|
bd4d0c2708 | ||
|
|
1d04ad6306 | ||
|
|
a4fa5f6f59 | ||
|
|
7e973d568a | ||
|
|
d81c52209b | ||
|
|
72744b3082 | ||
|
|
ff4efe2712 | ||
|
|
2982b90469 | ||
|
|
428a5df038 | ||
|
|
f79bf784b5 | ||
|
|
3b7449ac08 | ||
|
|
37bbfdb7ae | ||
|
|
eb570d2d09 | ||
|
|
c8c2a10b2d | ||
|
|
92c79eb2fb | ||
|
|
e70b45498d | ||
|
|
2e1ecfa731 | ||
|
|
8d323a7762 | ||
|
|
8aa0ff936a | ||
|
|
c6806d60c7 | ||
|
|
a13eb25ebc | ||
|
|
228d8a0686 | ||
|
|
0de1bc7ce3 | ||
|
|
6a94cad04b | ||
|
|
8643d25cc8 | ||
|
|
e820f11dda | ||
|
|
3555ab2b71 | ||
|
|
6e41d58131 | ||
|
|
6cf3beae1c | ||
|
|
022b76e784 | ||
|
|
2fbe88f806 | ||
|
|
9457bc32ca | ||
|
|
3de2639ae9 | ||
|
|
c43e549224 | ||
|
|
fc6fe5007b | ||
|
|
829febf33b | ||
|
|
ae99ba14b9 | ||
|
|
a158dc3a17 | ||
|
|
8420550990 | ||
|
|
156667cdbd | ||
|
|
7d388a8c91 | ||
|
|
dfa8776b2b | ||
|
|
1b9f15cb67 | ||
|
|
b683615269 | ||
|
|
a60ca2f6f5 | ||
|
|
14a2ead79f | ||
|
|
b625a6b4d4 | ||
|
|
fd12aa0a8d |
@@ -14,7 +14,7 @@ runs:
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 8.5.1
|
||||
version: 8.6.2
|
||||
run_install: false
|
||||
- name: Get pnpm cache directory
|
||||
id: pnpm-cache-dir
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
- name: Use Node.js 16
|
||||
- name: Use Node.js v18
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
1
.github/workflows/changesets.yaml
vendored
1
.github/workflows/changesets.yaml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
- '**.md'
|
||||
- '!.changeset/**'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
BUILD: 'all'
|
||||
- name: Check if the pnpm lockfile changed
|
||||
id: changed-lockfile
|
||||
uses: tj-actions/changed-files@v36
|
||||
uses: tj-actions/changed-files@v37
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
# * Determine a pnpm filter argument for packages that have been modified.
|
||||
@@ -146,6 +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
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -62,3 +62,7 @@ todo.md
|
||||
|
||||
# Nhost CLI data
|
||||
.nhost
|
||||
|
||||
# Nix
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
103
DEVELOPERS.md
103
DEVELOPERS.md
@@ -1,16 +1,37 @@
|
||||
# Developer guide
|
||||
# Developer Guide
|
||||
|
||||
## Requirements
|
||||
|
||||
- This repository works with **Node 16**
|
||||
### Node.js v18
|
||||
|
||||
- We use [pnpm](https://pnpm.io/) as a package manager to speed up development and builds, and as a basis for our monorepo. You need to make sure it's installed on your machine. There are [several ways to install it](https://pnpm.io/installation), but the easiest way is with `npm`:
|
||||
_⚠️ Node.js v16 is also supported for the time being but support will be dropped in the near future_.
|
||||
|
||||
### [pnpm](https://pnpm.io/) package manager
|
||||
|
||||
The easiest way to install `pnpm` if it's not installed on your machine yet is to use `npm`:
|
||||
|
||||
```sh
|
||||
$ npm install -g pnpm
|
||||
```
|
||||
|
||||
- Our tests and examples use the Nhost CLI, to run the backend services locally. You can follow the installation instructions in [our documentation](https://docs.nhost.io/get-started/cli-workflow/install-cli).
|
||||
### [Nhost CLI](https://docs.nhost.io/cli)
|
||||
|
||||
- The CLI is primarily used for running the E2E tests
|
||||
- Please refer to the [installation guide](https://docs.nhost.io/get-started/cli-workflow/install-cli) if you have not installed it yet
|
||||
|
||||
## File Structure
|
||||
|
||||
The repository is organized as a monorepo, with the following structure (only relevant folders are shown):
|
||||
|
||||
```
|
||||
assets/ # Assets used in the README
|
||||
config/ # Configuration files for the monorepo
|
||||
dashboard/ # Dashboard
|
||||
docs/ # Documentation website
|
||||
examples/ # Example projects
|
||||
packages/ # Core packages
|
||||
integrations/ # These are packages that rely on the core packages
|
||||
```
|
||||
|
||||
## Get started
|
||||
|
||||
@@ -31,25 +52,25 @@ $ pnpm install
|
||||
|
||||
### Development
|
||||
|
||||
Although package references are correctly updated on the fly for TypeScript, example projects won't
|
||||
see the changes because they are depending on the build output. To fix this, you can run packages
|
||||
in development mode.
|
||||
Although package references are correctly updated on the fly for TypeScript, example projects and the dashboard won't see the changes because they are depending on the build output. To fix this, you can run packages in development mode.
|
||||
|
||||
Running packages in development mode is as simple as:
|
||||
Running packages in development mode from the root folder is as simple as:
|
||||
|
||||
```sh
|
||||
$ pnpm dev
|
||||
```
|
||||
|
||||
Our packages are linked together using [PNPM's workspace](https://pnpm.io/workspaces) feature. Vite automatically detects changes in the dependencies and rebuilds everything, so that the changes are immediately reflected in the other packages.
|
||||
Our packages are linked together using [PNPM's workspace](https://pnpm.io/workspaces) feature. Next.js and Vite automatically detect changes in the dependencies and rebuild everything, so the changes will be reflected in the examples and the dashboard.
|
||||
|
||||
### Use examples
|
||||
**Note:** It's possible that Next.js or Vite throw an error when you run `pnpm dev`. Restarting the process should fix it.
|
||||
|
||||
### Use Examples
|
||||
|
||||
Examples are a great way to test your changes in practice. Make sure you've `pnpm dev` running in your terminal and then run an example.
|
||||
|
||||
Let's follow the instructions to run [react-apollo example](https://github.com/nhost/nhost/blob/main/examples/react-apollo/README.md).
|
||||
|
||||
## Run the documentation website locally
|
||||
## Edit Documentation
|
||||
|
||||
The easier way to contribute to our documentation is to go to the `docs` folder and follow the [instructions to start local development](https://github.com/nhost/nhost/blob/main/docs/README.md):
|
||||
|
||||
@@ -60,9 +81,9 @@ $ pnpm install
|
||||
$ pnpm start
|
||||
```
|
||||
|
||||
## Run test suites
|
||||
## Run Test Suites
|
||||
|
||||
### Unit tests
|
||||
### Unit Tests
|
||||
|
||||
You can run the unit tests with the following command from the repository root:
|
||||
|
||||
@@ -70,7 +91,7 @@ You can run the unit tests with the following command from the repository root:
|
||||
$ pnpm test
|
||||
```
|
||||
|
||||
### End-to-end tests
|
||||
### E2E Tests
|
||||
|
||||
Each package that defines end-to-end tests embeds their own Nhost configuration, that will be automatically when running the tests. As a result, you must make sure you are not running the Nhost CLI before running the tests.
|
||||
|
||||
@@ -83,24 +104,60 @@ $ pnpm e2e
|
||||
## Changesets
|
||||
|
||||
If you've made changes to the packages, you must describe those changes so that they can be reflected in the next release.
|
||||
We use [changesets](https://github.com/changesets/changesets) to support our versioning and release workflows. When you submit a pull request, a bot checks if some changesets are present, and if not, it directs you to add them.
|
||||
We use [changesets](https://github.com/changesets/changesets) to support our versioning and release workflows. When you submit a pull request, a bot checks if changesets are present, and if not, it asks you to add them.
|
||||
|
||||
The most comprehensive way to add a changeset is to run the following command in the repository root:
|
||||
To create a changeset, run the following command from the repository root:
|
||||
|
||||
```sh
|
||||
$ pnpm changeset
|
||||
```
|
||||
|
||||
This will create a file in the `.changeset` directory. You can edit it to give more details about the change you just made.
|
||||
This command will guide you through the process of creating a changeset. It will create a file in the `.changeset` directory.
|
||||
|
||||
You can take a look at the changeset documentation: [How to add a changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
|
||||
|
||||
## Committing changes
|
||||
### Selecting the Version
|
||||
|
||||
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
|
||||
When you create a changeset, you will be asked to select the version of the package that you are bumping. The versioning scheme is as follows:
|
||||
|
||||
- **major**
|
||||
- For breaking changes (e.g: changing the function signature, etc.)
|
||||
- Should be avoided as much as possible as it will require users to update their code. Instead, consider supporting both the old and the new API simultaneously for a while.
|
||||
- For example: `v1.5.8` -> `v2.0.0`
|
||||
- **minor**
|
||||
- For new features (e.g: adding a new page to the dashboard, etc.)
|
||||
- For example: `v1.5.8` -> `v1.6.0`
|
||||
- **patch**
|
||||
- For bug fixes (e.g: fixing a typo, etc.)
|
||||
- For example: `v1.5.8` -> `v1.5.9`
|
||||
|
||||
<!-- ## Good practices
|
||||
- lint
|
||||
- prettier
|
||||
- documentation -->
|
||||
### Writing Good Changesets
|
||||
|
||||
A concise summary that describes the changes should be added to each PR. This summary will be used as the changeset description.
|
||||
|
||||
The following structure is used for describing changes:
|
||||
|
||||
- **The type of the change**:
|
||||
|
||||
- fix
|
||||
- feat
|
||||
- chore
|
||||
- docs
|
||||
|
||||
- **The scope of the change** (_broader scopes (e.g: dashboard, hasura-storage-js, etc.) are not recommended as GitHub Releases already contain which project is being bumped_):
|
||||
|
||||
- projects
|
||||
- deployments
|
||||
- deps
|
||||
- etc.
|
||||
|
||||
- **A short summary of the changes that were made**
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `fix(deployments): use correct timestamp for deployment details`
|
||||
- `chore(deps): bump @types/react to v18.2.8`
|
||||
- `feat(secrets): enable secrets`
|
||||
- etc.
|
||||
|
||||
You can always take a look at examples of changesets in the [GitHub Releases section](https://github.com/nhost/nhost/releases).
|
||||
|
||||
@@ -34,7 +34,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- Serverless Functions: Node.js (JavaScript and TypeScript)
|
||||
- [Nhost CLI](https://docs.nhost.io/reference/cli) for local development
|
||||
- [Nhost CLI](https://docs.nhost.io/cli) for local development
|
||||
|
||||
## Architecture of Nhost
|
||||
|
||||
@@ -97,7 +97,7 @@ Nhost is frontend agnostic, which means Nhost works with all frontend frameworks
|
||||
|
||||
# Resources
|
||||
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/reference/cli)
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/cli)
|
||||
|
||||
## Nhost Clients
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import baseLibConfig from './vite.lib.config'
|
||||
export default defineConfig({
|
||||
...baseLibConfig,
|
||||
optimizeDeps: {
|
||||
include: ['react/jsx-runtime']
|
||||
include: ['react/jsx-runtime'],
|
||||
exclude: ['react-hook-form']
|
||||
},
|
||||
plugins: [react({ jsxRuntime: 'classic' }), ...baseLibConfig.plugins]
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@ import '@fontsource/inter';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { NhostClient, NhostProvider } from '@nhost/nextjs';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Buffer } from 'buffer';
|
||||
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||
import { RouterContext } from 'next/dist/shared/lib/router-context';
|
||||
import { createTheme } from '../src/components/ui/v2/createTheme';
|
||||
import '../src/styles/globals.css';
|
||||
import createTheme from '../src/theme/createTheme';
|
||||
|
||||
global.Buffer = Buffer;
|
||||
|
||||
@@ -56,5 +57,10 @@ export const decorators = [
|
||||
<Story />
|
||||
</NhostApolloProvider>
|
||||
),
|
||||
(Story) => (
|
||||
<NhostProvider nhost={new NhostClient({ subdomain: 'local' })}>
|
||||
<Story />
|
||||
</NhostProvider>
|
||||
),
|
||||
mswDecorator,
|
||||
];
|
||||
|
||||
@@ -1,5 +1,142 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3d5c34f4c: fix(auth): fix users pagination limit
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c99d117d1: feat(services): add support for custom services
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- face99ccd: chore(deps): bump turbo version
|
||||
- cfe527307: style: tweak pull config warning in dark mode
|
||||
- a9d7da8af: chore(deps): update dependency @types/pluralize to ^0.0.30
|
||||
- 9aa4371ef: chore: add hasura-auth version 0.21.2
|
||||
- d14e112bf: chore(deps): update dependency prettier-plugin-tailwindcss to ^0.4.0
|
||||
- d3e8bb94a: chore(deps): update dependency vite-plugin-dts to v3
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.32
|
||||
- @nhost/nextjs@1.13.34
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9c61c69a7: chore(dashboard):add postgres 14.6-20230705-1 to the version selector
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 47bda15ff: feat(settings): add warning to pull config
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ee0b9b8ed: chore(dashboard):add hasura v2.28.2 and v2.29.0 to the version selector
|
||||
|
||||
## 0.17.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.31
|
||||
- @nhost/nextjs@1.13.33
|
||||
|
||||
## 0.17.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f866120a6: fix(users): use the password length from the config
|
||||
|
||||
## 0.17.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.30
|
||||
- @nhost/nextjs@1.13.32
|
||||
|
||||
## 0.17.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea7b102c0: fix(pat): highlight expired tokens
|
||||
|
||||
## 0.17.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b3b64a3b7: chore(deps): bump `@types/react` to `v18.2.14` and `@types/react-dom` to `v18.2.6`
|
||||
- 32b221f94: chore(deps): bump `graphiql` to `v3`
|
||||
- 3a56c12df: chore(deps): bump `turbo` to `v1.10.6`
|
||||
- Updated dependencies [b3b64a3b7]
|
||||
- @nhost/react-apollo@5.0.29
|
||||
- @nhost/nextjs@1.13.31
|
||||
|
||||
## 0.17.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f41fdc12a: chore(deps): bump `turbo` to `1.10.5`
|
||||
- 6199c1c55: fix(projects): don't redirect to 404 page
|
||||
- Updated dependencies [07a45fde0]
|
||||
- @nhost/react-apollo@5.0.28
|
||||
- @nhost/nextjs@1.13.30
|
||||
|
||||
## 0.17.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 80b22724d: chore(deps): bump `@types/react` to `v18.2.13`, `@types/react-dom` to `v18.2.6` and `@storybook/testing-library` to `v0.2.0`
|
||||
|
||||
## 0.17.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc02902cb: chore(docs): update environment variable documentation
|
||||
|
||||
## 0.17.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 660d339e1: fix(storybook): don't break storybook
|
||||
- 660d339e1: fix(tests): prevent warnings during tests
|
||||
- @nhost/react-apollo@5.0.27
|
||||
- @nhost/nextjs@1.13.29
|
||||
|
||||
## 0.17.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bd4d0c270: chore(dashboard):add postgres 14.6-20230613-1 to the version selector
|
||||
|
||||
## 0.17.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c8c2a10b2: fix(database): don't break the password reset flow
|
||||
- e70b45498: chore(deps): bump `@types/react` to `v18.2.12` and `@types/react-dom` to `v18.2.5`
|
||||
|
||||
## 0.17.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 842055099: chore(deps): bump `turbo` to `v1.10.3` and `pnpm` to `v8.6.2`
|
||||
- fd12aa0a8: chore(projects): remove the postgres password input from the project creation screen
|
||||
- 022b76e78: chore(deps): bump `@types/react` to `v18.2.11`
|
||||
- 3555ab2b7: chore(deps): bump `vitest` monorepo to `v0.32.0`
|
||||
- c43e54922: feat(backups): add download button to backups
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo@1.10.1
|
||||
RUN yarn global add turbo@1.10.7
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
@@ -29,7 +29,7 @@ ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL __NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL_
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL __NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL __NEXT_PUBLIC_NHOST_HASURA_API_URL__
|
||||
|
||||
RUN yarn global add pnpm@8.5.1
|
||||
RUN yarn global add pnpm@8.6.2
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
This is the Nhost Dashboard, a web application that allows you to manage your Nhost projects.
|
||||
To get started, you need to have an Nhost project. If you don't have one, you can [create a project here](https://app.nhost.io).
|
||||
|
||||
First, install the dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Then, build the packages that are used by the Nhost Dashboard:
|
||||
|
||||
```bash
|
||||
pnpm -w build
|
||||
```
|
||||
|
||||
Finally, run the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to see the result in your browser.
|
||||
|
||||
## Environment
|
||||
|
||||
### Setup Environment Variables
|
||||
@@ -54,6 +70,12 @@ Components are documented using [Storybook](https://storybook.js.org/). To run S
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
By default, Storybook will run on port `6006`. You can change this by passing the `--port` flag:
|
||||
|
||||
```bash
|
||||
pnpm storybook --port 6007
|
||||
```
|
||||
|
||||
### General Environment Variables
|
||||
|
||||
| Name | Description |
|
||||
@@ -110,15 +132,19 @@ pnpm storybook
|
||||
| `@typescript-eslint/naming-convention` | Enforces a consistent naming convention. |
|
||||
| `no-restricted-imports` | Enforces absolute imports and consistent import paths for components from `src/components/ui` folder. |
|
||||
|
||||
### End-to-End Tests
|
||||
### Unit Tests
|
||||
|
||||
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
|
||||
Unit tests are written using [Vitest](https://vitest.dev/). To run the tests, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm e2e
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Most of the tests require access to the Nhost test user. To run these tests, you need to set the following environment variables in `.env.test`:
|
||||
### End-to-End Tests
|
||||
|
||||
Most of the end-to-end tests require access to an Nhost test user and a live project. You can register a user and create a test project on the [Nhost Dashboard](https://app.nhost.io/).
|
||||
|
||||
Next, you need to create a project. Create a `.env.test` file with the following variables:
|
||||
|
||||
```
|
||||
NHOST_TEST_DASHBOARD_URL=<test_dashboard_url>
|
||||
@@ -128,3 +154,20 @@ NHOST_TEST_WORKSPACE_NAME=<test_workspace_name>
|
||||
NHOST_TEST_PROJECT_NAME=<test_project_name>
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
|
||||
```
|
||||
|
||||
**Required Variables**:
|
||||
|
||||
- `NHOST_TEST_DASHBOARD_URL`: The URL to run the tests against (e.g: http://localhost:3000 or https://staging.app.nhost.io)
|
||||
- `NHOST_TEST_USER_EMAIL`: Email address of the test user that owns the test project
|
||||
- `NHOST_TEST_USER_PASSWORD`: Password of the test user that owns the test project
|
||||
- `NHOST_TEST_WORKSPACE_NAME`: Name of the workspace that contains the test project
|
||||
- `NHOST_TEST_PROJECT_NAME`: Name of the test project
|
||||
- `NHOST_TEST_PROJECT_ADMIN_SECRET`: Admin secret of the test project
|
||||
|
||||
Make sure to copy the workspace and project information from the [Nhost Dashboard](https://app.nhost.io/).
|
||||
|
||||
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
@@ -9,7 +9,7 @@ import { openProject } from '@/e2e/utils';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
async function globalTeardown() {
|
||||
const browser = await chromium.launch();
|
||||
const browser = await chromium.launch({ slowMo: 1000 });
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
@@ -46,18 +46,23 @@ async function globalTeardown() {
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).click();
|
||||
await hasuraPage.getByRole('link', { name: /sql/i }).click();
|
||||
|
||||
await hasuraPage.locator('#raw_sql > textarea').fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
||||
await hasuraPage.evaluate(() => {
|
||||
const editor = ace.edit('raw_sql');
|
||||
|
||||
editor.setValue(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
});
|
||||
|
||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
||||
|
||||
5
dashboard/hypertune.graphql
Normal file
5
dashboard/hypertune.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
query InitQuery {
|
||||
root {
|
||||
enableServices
|
||||
}
|
||||
}
|
||||
5
dashboard/hypertune.json
Normal file
5
dashboard/hypertune.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projectId": 2596,
|
||||
"token": "U2FsdGVkX19+V8BJnVR0xLEC+42OW5qZl/A0i6beAaRmJoIhFh5Yf6eIKBzLbV9h",
|
||||
"outputDirectoryPath": "src/hypertune"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.17.8",
|
||||
"version": "0.20.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -11,11 +11,11 @@
|
||||
"lint": "next lint --max-warnings 0",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"nhost:dev": "nhost up",
|
||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"e2e": "npx playwright@1.34.0 install --with-deps && playwright test"
|
||||
"install-browsers": "pnpm dlx playwright@1.31.0 install --with-deps",
|
||||
"e2e": "pnpm install-browsers && pnpm dlx playwright@1.31.0 test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.10",
|
||||
@@ -26,7 +26,7 @@
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@graphiql/react": "^0.17.0",
|
||||
"@graphiql/react": "^0.18.0",
|
||||
"@graphiql/toolkit": "^0.8.2",
|
||||
"@headlessui/react": "^1.6.5",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
@@ -49,11 +49,12 @@
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.4.0",
|
||||
"graphiql": "^3.0.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^6.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.11.2",
|
||||
"hypertune": "^1.4.4",
|
||||
"just-kebab-case": "^4.1.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "^12.3.1",
|
||||
@@ -71,6 +72,7 @@
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.5",
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.8.0",
|
||||
@@ -87,7 +89,7 @@
|
||||
"@graphql-codegen/typescript-operations": "^3.0.0",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.1",
|
||||
"@next/bundle-analyzer": "^12.3.1",
|
||||
"@playwright/test": "^1.34.0",
|
||||
"@playwright/test": "1.31.0",
|
||||
"@storybook/addon-actions": "^6.5.14",
|
||||
"@storybook/addon-essentials": "^6.5.14",
|
||||
"@storybook/addon-interactions": "^6.5.14",
|
||||
@@ -96,23 +98,26 @@
|
||||
"@storybook/builder-webpack5": "^6.5.14",
|
||||
"@storybook/manager-webpack5": "^6.5.14",
|
||||
"@storybook/react": "^6.5.14",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/ace": "^0.0.48",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.2.8",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@types/pluralize": "^0.0.30",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/shell-quote": "^1.7.1",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.31.0",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
@@ -136,7 +141,7 @@
|
||||
"postcss": "^8.4.19",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.4.0",
|
||||
"react-date-fns-hooks": "^0.9.4",
|
||||
"require-from-string": "^2.0.2",
|
||||
"snake-case": "^3.0.4",
|
||||
@@ -146,7 +151,7 @@
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.31.0"
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
* Total number of pages.
|
||||
*/
|
||||
totalNrOfPages: number;
|
||||
|
||||
/**
|
||||
* Number of total elements per page.
|
||||
*/
|
||||
@@ -23,6 +24,10 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
* Total number of elements.
|
||||
*/
|
||||
totalNrOfElements: number;
|
||||
/**
|
||||
* Label of the elements displayed ex: pages, users...
|
||||
*/
|
||||
itemsLabel: string;
|
||||
/**
|
||||
* Current page number.
|
||||
*/
|
||||
@@ -64,6 +69,7 @@ export default function Pagination({
|
||||
elementsPerPage,
|
||||
onPageChange,
|
||||
totalNrOfElements,
|
||||
itemsLabel,
|
||||
...props
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
@@ -132,7 +138,7 @@ export default function Pagination({
|
||||
{totalNrOfElements < currentPageNumber * elementsPerPage
|
||||
? totalNrOfElements
|
||||
: currentPageNumber * elementsPerPage}{' '}
|
||||
of {totalNrOfElements} users
|
||||
of {totalNrOfElements} {itemsLabel}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function SettingsContainer({
|
||||
<Box
|
||||
{...root}
|
||||
className={twMerge(
|
||||
'grid grid-flow-row gap-4 rounded-lg border-1 py-4',
|
||||
'grid grid-flow-row gap-4 overflow-hidden rounded-lg border-1 py-4',
|
||||
root?.className || rootClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
import { SettingsSidebar } from '@/components/layout/SettingsSidebar';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsLayoutProps extends ProjectLayoutProps {
|
||||
@@ -22,6 +26,10 @@ export default function SettingsLayout({
|
||||
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
|
||||
...props
|
||||
}: SettingsLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const hasGitRepo = !!currentProject?.githubRepository;
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{
|
||||
@@ -37,9 +45,46 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-x-hidden"
|
||||
className="flex flex-col flex-auto w-full overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row gap-2 place-content-center"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository and
|
||||
you only want these changes to apply to a subset of them, please
|
||||
check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { isK8SPostgresEnabledInCurrentEnvironment } from '@/utils/helpers';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -135,15 +134,13 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Compute Resources
|
||||
</SettingsNavLink>
|
||||
{isK8SPostgresEnabledInCurrentEnvironment && (
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Database
|
||||
</SettingsNavLink>
|
||||
)}
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Database
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/hasura"
|
||||
exact={false}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
export interface AlertProps extends BoxProps {
|
||||
/**
|
||||
@@ -11,19 +11,25 @@ export interface AlertProps extends BoxProps {
|
||||
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
borderRadius: 4,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
lineHeight: theme.typography.pxToRem(22),
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
transition: theme.transitions.create('background-color'),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Alert({
|
||||
severity = 'info',
|
||||
children,
|
||||
className,
|
||||
sx,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'rounded-sm+ bg-opacity-20 p-4 text-center text-sm+ motion-safe:transition-colors',
|
||||
className,
|
||||
)}
|
||||
<StyledBox
|
||||
sx={[
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
severity === 'error' && {
|
||||
@@ -43,6 +49,6 @@ export default function Alert({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { textClasses } from '@/components/ui/v2/Text';
|
||||
import { getTypographyUtilityClass, styled } from '@mui/material';
|
||||
import type { ListItemTextProps as MaterialListItemTextProps } from '@mui/material/ListItemText';
|
||||
import MaterialListItemText, {
|
||||
listItemTextClasses,
|
||||
listItemTextClasses as materialListItemTextClasses,
|
||||
} from '@mui/material/ListItemText';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ListItemTextProps extends MaterialListItemTextProps {}
|
||||
|
||||
const listItemTextClasses = {
|
||||
...materialListItemTextClasses,
|
||||
warning: getTypographyUtilityClass('colorWarning'),
|
||||
};
|
||||
|
||||
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
color: theme.palette.text.primary,
|
||||
display: 'grid',
|
||||
@@ -16,6 +23,9 @@ const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
[`&.${listItemTextClasses.root}`]: {
|
||||
margin: 0,
|
||||
},
|
||||
[`&.${listItemTextClasses.warning}`]: {
|
||||
color: theme.palette.warning.dark,
|
||||
},
|
||||
[`& > .${listItemTextClasses.primary}`]: {
|
||||
fontWeight: 500,
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -29,8 +39,23 @@ const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function ListItemText({ children, ...props }: ListItemTextProps) {
|
||||
return <StyledListItemText {...props}>{children}</StyledListItemText>;
|
||||
function ListItemText({
|
||||
children,
|
||||
color = 'primary',
|
||||
className,
|
||||
...props
|
||||
}: ListItemTextProps) {
|
||||
return (
|
||||
<StyledListItemText
|
||||
className={clsx(
|
||||
color === 'warning' && textClasses.colorWarning,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledListItemText>
|
||||
);
|
||||
}
|
||||
|
||||
ListItemText.displayName = 'NhostListItemText';
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TextProps<
|
||||
*
|
||||
* @default 'primary'
|
||||
*/
|
||||
color?: 'primary' | 'secondary' | 'disabled' | 'error';
|
||||
color?: 'primary' | 'secondary' | 'disabled' | 'error' | 'warning';
|
||||
/**
|
||||
* The component used for the root node.
|
||||
*/
|
||||
@@ -31,6 +31,7 @@ const textClasses = {
|
||||
colorSecondary: getTypographyUtilityClass('colorSecondary'),
|
||||
colorDisabled: getTypographyUtilityClass('colorDisabled'),
|
||||
colorError: getTypographyUtilityClass('colorError'),
|
||||
colorWarning: getTypographyUtilityClass('colorWarning'),
|
||||
};
|
||||
|
||||
const StyledTypography = styled(MaterialTypography)<TextProps>(({ theme }) => ({
|
||||
@@ -50,6 +51,9 @@ const StyledTypography = styled(MaterialTypography)<TextProps>(({ theme }) => ({
|
||||
[`&.${textClasses.colorError}`]: {
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
[`&.${textClasses.colorWarning}`]: {
|
||||
color: theme.palette.warning.dark,
|
||||
},
|
||||
}));
|
||||
|
||||
function Text<
|
||||
@@ -70,6 +74,7 @@ function Text<
|
||||
color === 'secondary' && textClasses.colorSecondary,
|
||||
color === 'disabled' && textClasses.colorDisabled,
|
||||
color === 'error' && textClasses.colorError,
|
||||
color === 'warning' && textClasses.colorWarning,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
40
dashboard/src/components/ui/v2/icons/CubeIcon/CubeIcon.tsx
Normal file
40
dashboard/src/components/ui/v2/icons/CubeIcon/CubeIcon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
|
||||
function CubeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 11.0826V4.91742C14 4.8287 13.9764 4.74158 13.9316 4.665C13.8868 4.58841 13.8225 4.52513 13.7451 4.48163L8.24513 1.38788C8.17029 1.34578 8.08587 1.32367 8 1.32367C7.91413 1.32367 7.82971 1.34578 7.75487 1.38788L2.25487 4.48163C2.17754 4.52513 2.11318 4.58841 2.0684 4.665C2.02361 4.74158 2 4.8287 2 4.91742V11.0826C2 11.1713 2.02361 11.2584 2.0684 11.335C2.11318 11.4116 2.17754 11.4749 2.25487 11.5184L7.75487 14.6121C7.82971 14.6542 7.91413 14.6763 8 14.6763C8.08587 14.6763 8.17029 14.6542 8.24513 14.6121L13.7451 11.5184C13.8225 11.4749 13.8868 11.4116 13.9316 11.335C13.9764 11.2584 14 11.1713 14 11.0826Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.9311 4.66414L8.0594 8.00001L2.06934 4.66357"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.05916 8L8.00049 14.6763"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
CubeIcon.displayName = 'NhostCubeIcon';
|
||||
|
||||
export default CubeIcon;
|
||||
1
dashboard/src/components/ui/v2/icons/CubeIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/CubeIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CubeIcon } from './CubeIcon';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ServicesIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Services"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.89295 4.15125H9.21701C9.28097 4.15125 9.33291 4.09959 9.33326 4.03565V2.8556C9.33291 2.79163 9.28097 2.73999 9.21701 2.73999H7.89295C7.82909 2.73999 7.77734 2.79174 7.77734 2.8556V4.03562C7.77734 4.09948 7.82911 4.15125 7.89295 4.15125ZM5.53406 5.84862H4.21001C4.14594 5.84826 4.09411 5.79643 4.09375 5.73236V4.55298C4.09411 4.48902 4.14606 4.43738 4.21001 4.43738H5.53406C5.5979 4.43738 5.64967 4.48912 5.64967 4.55298V5.73236C5.64967 5.79631 5.59801 5.84826 5.53406 5.84862ZM14.6307 6.48419C15.4316 6.48419 15.8114 6.77094 15.8521 6.80325L16 6.92016L15.9386 7.09971C15.8408 7.34738 15.69 7.57067 15.4968 7.75398C15.2062 8.04139 14.6791 8.38436 13.8221 8.38436H13.6839C13.337 9.26145 12.8707 10.2484 12.0879 11.1345C11.6196 11.6644 11.0689 12.1152 10.457 12.4696C9.71438 12.8901 8.90665 13.1835 8.06725 13.3376C7.4634 13.45 6.85036 13.5056 6.23616 13.5036C4.87658 13.5036 3.67717 13.2453 2.93893 12.7932C2.28012 12.3908 1.77374 11.7333 1.43337 10.8407C1.13576 10.0277 0.989105 9.1673 1.00063 8.30169C1.00204 8.04363 1.21146 7.83507 1.46954 7.83472H11.3503C11.471 7.8302 12.0678 7.77917 12.4399 7.57185C12.1318 7.08484 12.0446 6.51519 12.188 5.9087C12.2639 5.59123 12.3932 5.28898 12.5703 5.01479L12.7118 4.81068L12.9268 4.93471L12.9269 4.93473C12.9668 4.9583 13.8447 5.47632 13.9996 6.53843C14.2082 6.50325 14.4192 6.48511 14.6307 6.48419ZM3.7092 7.54529H2.38514C2.32128 7.54529 2.26953 7.49353 2.26953 7.42967V6.25029V6.24964C2.26953 6.1858 2.32128 6.13403 2.38514 6.13403H3.7092H3.70985C3.77369 6.13439 3.82516 6.18643 3.8248 6.25029V7.42969C3.8248 7.49353 3.77306 7.54529 3.7092 7.54529ZM4.21003 7.54529H5.53409C5.59794 7.54529 5.64969 7.49353 5.64969 7.42969V6.25029C5.65005 6.18643 5.59858 6.13439 5.53472 6.13403H5.53407H4.21001C4.14579 6.13403 4.09375 6.18607 4.09375 6.25029V7.42967C4.09413 7.49363 4.14606 7.54529 4.21003 7.54529ZM7.38597 7.54529H6.06191C5.99808 7.54529 5.94631 7.49353 5.94629 7.42967V6.25029V6.24964C5.94629 6.1858 5.99803 6.13403 6.06189 6.13403H7.38595H7.3866C7.45046 6.13439 7.50193 6.18643 7.50157 6.25029V7.42969C7.50157 7.49353 7.44983 7.54529 7.38597 7.54529ZM7.89295 7.54529H9.21701C9.28097 7.54529 9.33291 7.49365 9.33326 7.42969V6.25029C9.33326 6.18607 9.28122 6.13403 9.21701 6.13403H7.89295C7.82909 6.13403 7.77734 6.1858 7.77734 6.24964V6.25029V7.42967C7.77734 7.49353 7.82911 7.54529 7.89295 7.54529ZM6.06189 5.84862H7.38595C7.4499 5.84826 7.50156 5.79631 7.50156 5.73236V4.55298C7.50156 4.48912 7.44979 4.43738 7.38595 4.43738H6.06189C5.99804 4.43738 5.94629 4.48915 5.94629 4.55298V5.73236C5.94629 5.79631 5.99795 5.84826 6.06189 5.84862ZM9.21701 5.84862H7.89295C7.82901 5.84826 7.77734 5.79631 7.77734 5.73236V4.55298C7.77734 4.48915 7.82909 4.43738 7.89295 4.43738H9.21701C9.28097 4.43738 9.33291 4.48902 9.33326 4.55298V5.73236C9.33291 5.79643 9.28108 5.84826 9.21701 5.84862ZM11.0637 7.54529H9.73963C9.67579 7.54529 9.62402 7.49353 9.62402 7.42967V6.25029V6.24964C9.62402 6.1858 9.67579 6.13403 9.73963 6.13403H11.0637C11.1279 6.13403 11.1799 6.18607 11.1799 6.25029V7.42969C11.1796 7.49365 11.1277 7.54529 11.0637 7.54529Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesIcon.displayName = 'NhostServicesIcon';
|
||||
|
||||
export default ServicesIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesIcon } from './ServicesIcon';
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function WarningIcon(props: IconProps, ref: ForwardedRef<SVGSVGElement>) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Warning"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 5.5V9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.135 2.49904L1.63648 11.9986C1.5485 12.1506 1.5021 12.3231 1.50195 12.4987C1.50181 12.6743 1.54792 12.8469 1.63565 12.999C1.72338 13.1512 1.84964 13.2776 2.00172 13.3654C2.15379 13.4533 2.32633 13.4995 2.50196 13.4995H13.499C13.6746 13.4995 13.8472 13.4533 13.9992 13.3654C14.1513 13.2776 14.2776 13.1512 14.3653 12.999C14.453 12.8469 14.4991 12.6743 14.499 12.4987C14.4988 12.3231 14.4524 12.1506 14.3645 11.9986L8.86594 2.49904C8.7781 2.34728 8.6519 2.22129 8.49999 2.1337C8.34809 2.04611 8.17582 2 8.00047 2C7.82512 2 7.65285 2.04611 7.50095 2.1337C7.34904 2.22129 7.22284 2.34728 7.135 2.49904V2.49904Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 12C8.41421 12 8.75 11.6642 8.75 11.25C8.75 10.8358 8.41421 10.5 8 10.5C7.58579 10.5 7.25 10.8358 7.25 11.25C7.25 11.6642 7.58579 12 8 12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
WarningIcon.displayName = 'NhostWarningIcon';
|
||||
|
||||
export default forwardRef(WarningIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WarningIcon } from './WarningIcon';
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"k8s-postgres": {
|
||||
"enabled": ["dev", "staging", "production"]
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { DotsVerticalIcon } from '@/components/ui/v2/icons/DotsVerticalIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { WarningIcon } from '@/components/ui/v2/icons/WarningIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { CreatePATForm } from '@/features/account/settings/components/CreatePATForm';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
@@ -133,69 +135,91 @@ export default function PATSettings() {
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
{availablePersonalAccessTokens.length > 0 && (
|
||||
<List>
|
||||
{availablePersonalAccessTokens.map((pat, index) => (
|
||||
<Fragment key={pat.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-3 gap-2 px-4 pr-12"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
aria-label={`More options for ${pat.name}`}
|
||||
{availablePersonalAccessTokens.map((pat, index) => {
|
||||
const tokenHasExpired = new Date(pat.expiresAt) < new Date();
|
||||
|
||||
return (
|
||||
<Fragment key={pat.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-3 gap-2 px-4 pr-12"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
aria-label={`More options for ${pat.name}`}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item onClick={() => handleConfirmDelete(pat)}>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text className="truncate">{pat.name}</ListItem.Text>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleConfirmDelete(pat)}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text
|
||||
className="truncate"
|
||||
color={tokenHasExpired ? 'warning' : 'primary'}
|
||||
>
|
||||
<span className="mr-2">{pat.name}</span>
|
||||
{tokenHasExpired && (
|
||||
<Tooltip title="This personal access token is expired.">
|
||||
<WarningIcon className="h-4 w-4" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ListItem.Text>
|
||||
|
||||
<Text className="truncate">
|
||||
{new Date(pat.expiresAt).toLocaleDateString()}
|
||||
</Text>
|
||||
<Text
|
||||
className="truncate"
|
||||
color={tokenHasExpired ? 'warning' : 'primary'}
|
||||
>
|
||||
{new Date(pat.expiresAt).toLocaleDateString()}
|
||||
</Text>
|
||||
|
||||
<Text className="truncate">
|
||||
{new Date(pat.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
<Text
|
||||
className="truncate"
|
||||
color={tokenHasExpired ? 'warning' : 'primary'}
|
||||
>
|
||||
{new Date(pat.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availablePersonalAccessTokens.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availablePersonalAccessTokens.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export type AuthServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_AUTH_VERSIONS = [
|
||||
'0.21.2',
|
||||
'0.20.1',
|
||||
'0.20.0',
|
||||
'0.19.3',
|
||||
|
||||
@@ -66,7 +66,10 @@ export default function WebAuthnSettings() {
|
||||
config: {
|
||||
auth: {
|
||||
method: {
|
||||
webauthn: values,
|
||||
webauthn: {...values,
|
||||
relyingParty: {
|
||||
name: currentProject.name,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,12 +3,16 @@ import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUpdateRemoteAppUserMutation } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { useState } from 'react';
|
||||
@@ -27,19 +31,6 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
}
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.label('Users Password')
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.required('This field is required.'),
|
||||
cpassword: Yup.string()
|
||||
.required('Confirm Password is required')
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.oneOf([Yup.ref('password')], 'Passwords do not match'),
|
||||
});
|
||||
|
||||
export type EditUserPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
onCancel,
|
||||
user,
|
||||
@@ -49,26 +40,52 @@ export default function EditUserPasswordForm({
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
const { closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { data } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !currentProject?.id,
|
||||
});
|
||||
|
||||
const passwordMinLength =
|
||||
data?.config?.auth?.method?.emailPassword?.passwordMinLength || 1;
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.label('Password')
|
||||
.min(
|
||||
passwordMinLength,
|
||||
`Password must be at least ${passwordMinLength} characters long.`,
|
||||
)
|
||||
.required('This field is required.'),
|
||||
cpassword: Yup.string()
|
||||
.label('Password Confirmation')
|
||||
.min(
|
||||
passwordMinLength,
|
||||
`Password must be at least ${passwordMinLength} characters long.`,
|
||||
)
|
||||
.oneOf([Yup.ref('password')], 'Passwords do not match')
|
||||
.required('This field is required.'),
|
||||
});
|
||||
|
||||
const [editUserPasswordFormError, setEditUserPasswordFormError] =
|
||||
useState<Error | null>(null);
|
||||
|
||||
const form = useForm<EditUserPasswordFormValues>({
|
||||
const form = useForm<Yup.InferType<typeof validationSchema>>({
|
||||
defaultValues: {},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const handleSubmit = async ({ password }: EditUserPasswordFormValues) => {
|
||||
const handleSubmit = async ({
|
||||
password,
|
||||
}: Yup.InferType<typeof validationSchema>) => {
|
||||
setEditUserPasswordFormError(null);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const updateUserPasswordPromise = updateUser({
|
||||
variables: {
|
||||
id: user.id,
|
||||
user: {
|
||||
passwordHash,
|
||||
},
|
||||
user: { passwordHash },
|
||||
},
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -34,7 +35,7 @@ const defaultParameters = {
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers: [tableQuery, hasuraMetadataQuery],
|
||||
handlers: [tokenQuery, tableQuery, hasuraMetadataQuery],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -187,11 +187,11 @@ export default async function fetchTable({
|
||||
const queryError = responseData as QueryError;
|
||||
const schemaNotFound =
|
||||
POSTGRESQL_ERROR_CODES.SCHEMA_NOT_FOUND ===
|
||||
queryError.internal.error.status_code;
|
||||
queryError.internal?.error?.status_code;
|
||||
|
||||
const tableNotFound =
|
||||
POSTGRESQL_ERROR_CODES.TABLE_NOT_FOUND ===
|
||||
queryError.internal.error.status_code;
|
||||
queryError.internal?.error?.status_code;
|
||||
|
||||
if (schemaNotFound || tableNotFound) {
|
||||
return {
|
||||
@@ -203,7 +203,7 @@ export default async function fetchTable({
|
||||
}
|
||||
|
||||
if (
|
||||
queryError.internal.error.status_code ===
|
||||
queryError.internal?.error?.status_code ===
|
||||
POSTGRESQL_ERROR_CODES.COLUMNS_NOT_FOUND
|
||||
) {
|
||||
return {
|
||||
@@ -214,7 +214,7 @@ export default async function fetchTable({
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(queryError.internal.error.message);
|
||||
throw new Error(queryError.internal?.error?.message);
|
||||
}
|
||||
|
||||
if ('error' in responseData) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
@@ -91,28 +92,33 @@ export default function DatabaseConnectionInfo() {
|
||||
disabled
|
||||
value={inputValue}
|
||||
className={className}
|
||||
slotProps={{ inputRoot: { className: '!pr-8 truncate' } }}
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
endAdornment={
|
||||
name !== 'postgresPassword' && (
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<Button
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(inputValue as string, `${label}`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
)
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<Button
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(inputValue as string, `${label}`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
To connect to the Postgres database directly, generate a new password,
|
||||
securely save it, and then modify your connection string with the newly
|
||||
created password.
|
||||
</Alert>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type DatabaseServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_POSTGRES_VERSIONS = [
|
||||
'14.6-20230705-1',
|
||||
'14.6-20230613-1',
|
||||
'14.6-20230525',
|
||||
'14.6-20230406-2',
|
||||
'14.6-20230406-1',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
@@ -6,30 +7,27 @@ import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { generateRandomDatabasePassword } from '@/features/database/common/utils/generateRandomDatabasePassword';
|
||||
import type { ResetDatabasePasswordFormValues } from '@/features/database/settings/utils/resetDatabasePasswordValidationSchema';
|
||||
import { resetDatabasePasswordValidationSchema } from '@/features/database/settings/utils/resetDatabasePasswordValidationSchema';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useResetPostgresPasswordMutation,
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useResetDatabasePasswordMutation } from '@/generated/graphql';
|
||||
import { useLeaveConfirm } from '@/hooks/useLeaveConfirm';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { alpha } from '@mui/system';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ResetDatabasePasswordFormValues {
|
||||
/**
|
||||
* The new password to set for the database.
|
||||
*/
|
||||
databasePassword: string;
|
||||
}
|
||||
|
||||
export default function ResetDatabasePasswordSettings() {
|
||||
const [updateApplication] = useUpdateApplicationMutation();
|
||||
const [resetPassword, { loading: resetPasswordLoading }] =
|
||||
useResetDatabasePasswordMutation();
|
||||
const { maintenanceActive } = useUI();
|
||||
const user = useUserData();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const form = useForm<ResetDatabasePasswordFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -46,41 +44,36 @@ export default function ResetDatabasePasswordSettings() {
|
||||
setValue,
|
||||
getValues,
|
||||
register,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
formState: { errors, dirtyFields, isSubmitting },
|
||||
} = form;
|
||||
|
||||
const [resetPostgresPasswordMutation] = useResetPostgresPasswordMutation();
|
||||
const user = useUserData();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
const handleGenerateRandomPassword = () => {
|
||||
useLeaveConfirm({ isDirty });
|
||||
|
||||
function handleGenerateRandomPassword() {
|
||||
const newRandomDatabasePassword = generateRandomDatabasePassword();
|
||||
triggerToast('New random database password generated.');
|
||||
triggerToast(
|
||||
'Random database password was generated and copied to clipboard. Submit the form to save it.',
|
||||
);
|
||||
copy(newRandomDatabasePassword);
|
||||
setValue('databasePassword', newRandomDatabasePassword, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const handleChangeDatabasePassword = async (
|
||||
values: ResetDatabasePasswordFormValues,
|
||||
) => {
|
||||
async function handleChangeDatabasePassword(
|
||||
formValues: ResetDatabasePasswordFormValues,
|
||||
) {
|
||||
try {
|
||||
await resetPostgresPasswordMutation({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
newPassword: values.databasePassword,
|
||||
},
|
||||
});
|
||||
await updateApplication({
|
||||
await resetPassword({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
postgresPassword: values.databasePassword,
|
||||
},
|
||||
newPassword: formValues.databasePassword,
|
||||
},
|
||||
});
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
|
||||
triggerToast(
|
||||
`The database password for ${currentProject.name} has been updated successfully.`,
|
||||
@@ -93,24 +86,45 @@ export default function ResetDatabasePasswordSettings() {
|
||||
`An error occurred while trying to update the database password: ${currentProject.name} (${user.email}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleSubmit(formValues: ResetDatabasePasswordFormValues) {
|
||||
openAlertDialog({
|
||||
title: 'Confirm Change',
|
||||
payload: 'Are you sure you want to change the database password?',
|
||||
props: {
|
||||
primaryButtonColor: 'error',
|
||||
primaryButtonText: 'Confirm',
|
||||
onPrimaryAction: () => handleChangeDatabasePassword(formValues),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleChangeDatabasePassword}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Reset Password"
|
||||
description="This password is used for accessing your database."
|
||||
submitButtonText="Reset"
|
||||
description="This password will be used for accessing your database."
|
||||
submitButtonText="Save"
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: { borderColor: (theme) => theme.palette.error.main },
|
||||
sx: {
|
||||
borderColor: (theme) =>
|
||||
isDirty
|
||||
? theme.palette.error.main
|
||||
: alpha(theme.palette.error.main, 0.5),
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
transition: (theme) =>
|
||||
theme.transitions.create('border-color'),
|
||||
},
|
||||
},
|
||||
},
|
||||
submitButton: {
|
||||
variant: 'contained',
|
||||
color: 'error',
|
||||
variant: isDirty ? 'contained' : 'outlined',
|
||||
color: isDirty ? 'error' : 'secondary',
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: isSubmitting,
|
||||
loading: isSubmitting || resetPasswordLoading,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row pb-4"
|
||||
@@ -126,6 +140,7 @@ export default function ResetDatabasePasswordSettings() {
|
||||
hideEmptyHelperText
|
||||
slotProps={{
|
||||
input: { className: 'lg:w-1/2' },
|
||||
inputRoot: { className: '!pr-8' },
|
||||
helperText: { component: 'div' },
|
||||
}}
|
||||
helperText={
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './ResetDatabasePasswordSettings';
|
||||
export { default as ResetDatabasePasswordSettings } from './ResetDatabasePasswordSettings';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation ResetDatabasePassword($appId: String!, $newPassword: String!) {
|
||||
resetPostgresPassword(appID: $appId, newPassword: $newPassword)
|
||||
}
|
||||
@@ -16,4 +16,8 @@ export const resetDatabasePasswordValidationSchema = yup.object().shape({
|
||||
.minUppercase(1),
|
||||
});
|
||||
|
||||
export type ResetDatabasePasswordFormValues = yup.InferType<
|
||||
typeof resetDatabasePasswordValidationSchema
|
||||
>;
|
||||
|
||||
export default resetDatabasePasswordValidationSchema;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { render, screen, waitFor } from '@/tests/testUtils';
|
||||
import { graphql } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
@@ -5,6 +6,7 @@ import { beforeAll, expect, test } from 'vitest';
|
||||
import HasuraCorsDomainSettings from './HasuraCorsDomainSettings';
|
||||
|
||||
const server = setupServer(
|
||||
tokenQuery,
|
||||
graphql.query('GetHasuraSettings', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
@@ -15,7 +17,14 @@ const server = setupServer(
|
||||
version: 'v2.25.1-ce',
|
||||
settings: {
|
||||
corsDomain: ['*'],
|
||||
enableAllowList: false,
|
||||
enableRemoteSchemaPermissions: false,
|
||||
enableConsole: false,
|
||||
devMode: false,
|
||||
enabledAPIs: [],
|
||||
},
|
||||
logs: [],
|
||||
events: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -56,7 +65,14 @@ test('should enable switch by default when CORS domain is set to one or more dom
|
||||
version: 'v2.25.1-ce',
|
||||
settings: {
|
||||
corsDomain: ['https://example.com', 'https://*.example.com'],
|
||||
enableAllowList: false,
|
||||
enableRemoteSchemaPermissions: false,
|
||||
enableConsole: false,
|
||||
devMode: false,
|
||||
enabledAPIs: [],
|
||||
},
|
||||
logs: [],
|
||||
events: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,9 @@ export type HasuraServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Table } from '@/components/ui/v2/Table';
|
||||
import { TableBody } from '@/components/ui/v2/TableBody';
|
||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||
import { TableContainer } from '@/components/ui/v2/TableContainer';
|
||||
import { TableHead } from '@/components/ui/v2/TableHead';
|
||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { BackupListItem } from '@/features/projects/backups/components/BackupListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useGetApplicationBackupsQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
export default function BackupList() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { data, loading, error } = useGetApplicationBackupsQuery({
|
||||
variables: { appId: currentProject.id },
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
className="my-5"
|
||||
label="Loading backups..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { backups } = data.app;
|
||||
|
||||
return (
|
||||
<TableContainer sx={{ backgroundColor: 'background.paper' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Backed up</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{backups.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Text className="text-xs" color="secondary">
|
||||
No backups are available.
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{backups.map((backup) => (
|
||||
<BackupListItem
|
||||
key={backup.id}
|
||||
backup={backup}
|
||||
projectId={currentProject.id}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as BackupList } from './BackupList';
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||
import { RestoreBackupModal } from '@/features/projects/backups/components/RestoreBackupModal';
|
||||
import type { Backup } from '@/types/application';
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useGetBackupPresignedUrlLazyQuery } from '@/utils/__generated__/graphql';
|
||||
import { format, formatDistanceStrict, parseISO } from 'date-fns';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface BackupListItemProps {
|
||||
/**
|
||||
* Project ID.
|
||||
*/
|
||||
projectId: string;
|
||||
/**
|
||||
* Backup data.
|
||||
*/
|
||||
backup: Backup;
|
||||
}
|
||||
|
||||
export default function BackupListItem({
|
||||
projectId,
|
||||
backup,
|
||||
}: BackupListItemProps) {
|
||||
const { id, createdAt, size } = backup;
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const [fetchPresignedUrl, { loading: loadingPresignedUrl }] =
|
||||
useGetBackupPresignedUrlLazyQuery({
|
||||
variables: {
|
||||
appId: projectId,
|
||||
backupId: id,
|
||||
},
|
||||
});
|
||||
|
||||
async function downloadBackup() {
|
||||
const { data: presignedUrlData, error } = await fetchPresignedUrl();
|
||||
|
||||
if (error) {
|
||||
triggerToast(
|
||||
'An error occurred while fetching the presigned URL. Please try again later.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(presignedUrlData.getBackupPresignedUrl.url, '_blank');
|
||||
}
|
||||
|
||||
function restoreBackup() {
|
||||
openDialog({
|
||||
title: 'Restore Backup',
|
||||
component: <RestoreBackupModal backup={backup} close={closeDialog} />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="text-xs">
|
||||
{format(parseISO(createdAt), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{prettifySize(size)}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{formatDistanceStrict(new Date(createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={twMerge(
|
||||
'grid grid-flow-col justify-end gap-2',
|
||||
!loadingPresignedUrl && 'pl-8',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={downloadBackup}
|
||||
loading={loadingPresignedUrl}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button variant="borderless" onClick={restoreBackup}>
|
||||
Restore
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BackupListItem';
|
||||
export { default as BackupListItem } from './BackupListItem';
|
||||
@@ -3,38 +3,38 @@ 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 type { Backup } from '@/types/application';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useRestoreApplicationDatabaseMutation } from '@/utils/__generated__/graphql';
|
||||
import { formatISO9075 } from 'date-fns';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface RestoreBackupModalModalProps {
|
||||
export interface RestoreBackupModalProps {
|
||||
/**
|
||||
* Call this function to imperatively close the modal.
|
||||
*/
|
||||
close: any;
|
||||
close: VoidFunction;
|
||||
/**
|
||||
* Arbitrary data passed down to the modal.
|
||||
*
|
||||
* Backup data.
|
||||
*/
|
||||
data: any;
|
||||
backup: Backup;
|
||||
}
|
||||
|
||||
export default function RestoreBackupModal({
|
||||
close,
|
||||
data,
|
||||
}: RestoreBackupModalModalProps) {
|
||||
const { id: backupId, createdAt } = data;
|
||||
backup,
|
||||
}: RestoreBackupModalProps) {
|
||||
const { id: backupId, createdAt } = backup;
|
||||
|
||||
const [isSure, setIsSure] = useState(false);
|
||||
const [mutationIsCompleted, setMutationIsCompleted] = useState(false);
|
||||
const [restoreCompleted, setRestoreCompleted] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [restoreApplicationDatabase, { loading }] =
|
||||
useRestoreApplicationDatabaseMutation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setMutationIsCompleted(false);
|
||||
async function handleSubmit() {
|
||||
setRestoreCompleted(false);
|
||||
try {
|
||||
await restoreApplicationDatabase({
|
||||
variables: {
|
||||
@@ -43,55 +43,49 @@ export default function RestoreBackupModal({
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setMutationIsCompleted(false);
|
||||
setRestoreCompleted(false);
|
||||
triggerToast('Database backup restoration failed');
|
||||
return;
|
||||
}
|
||||
setMutationIsCompleted(true);
|
||||
setRestoreCompleted(true);
|
||||
triggerToast('Database backup successfully scheduled for restoration.');
|
||||
};
|
||||
}
|
||||
|
||||
if (mutationIsCompleted) {
|
||||
if (restoreCompleted) {
|
||||
return (
|
||||
<Box className="w-modal rounded-lg p-6">
|
||||
<div className="flex flex-col">
|
||||
<Text className="text-center text-lg font-medium">
|
||||
The backup has been restored successfully.
|
||||
</Text>
|
||||
<Box className="grid grid-flow-row gap-4 px-6 pb-6">
|
||||
<Text>The backup has been restored successfully.</Text>
|
||||
|
||||
<Button className="mt-5" onClick={close}>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={close}>OK</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="w-modal rounded-lg px-6 py-6 text-left">
|
||||
<div className="flex flex-col">
|
||||
<Text className="text-center text-lg font-medium">
|
||||
Restore Database Backup
|
||||
</Text>
|
||||
<Text className="mt-2 text-center font-normal">
|
||||
You current database will be deleted, and the backup created{' '}
|
||||
<span className="font-semibold">
|
||||
{formatISO9075(new Date(createdAt))}
|
||||
</span>{' '}
|
||||
will be restored.
|
||||
</Text>
|
||||
<Box className="grid grid-flow-row gap-2 px-6 pb-6">
|
||||
<Text>
|
||||
You current database will be deleted, and the backup created at{' '}
|
||||
<span className="font-semibold">
|
||||
{format(parseISO(createdAt), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</span>{' '}
|
||||
will be restored.
|
||||
</Text>
|
||||
|
||||
<Box className="my-4 border-y py-2 px-2">
|
||||
<Checkbox
|
||||
checked={isSure}
|
||||
onChange={(_event, checked) => setIsSure(checked)}
|
||||
label="I'm sure I want to restore this backup."
|
||||
/>
|
||||
</Box>
|
||||
<Button onClick={handleSubmit} disabled={!isSure} loading={loading}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
<Box className="pt-1 pb-2.5">
|
||||
<Checkbox
|
||||
checked={isSure}
|
||||
onChange={(_event, checked) => setIsSure(checked)}
|
||||
label="I'm sure I want to restore this backup"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!isSure} loading={loading}>
|
||||
Restore
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
6
dashboard/src/features/projects/backups/gql/backup.gql
Normal file
6
dashboard/src/features/projects/backups/gql/backup.gql
Normal file
@@ -0,0 +1,6 @@
|
||||
fragment Backup on backups {
|
||||
id
|
||||
size
|
||||
createdAt
|
||||
completedAt
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
query getApplicationBackups($appId: uuid!) {
|
||||
app(id: $appId) {
|
||||
backups(order_by: { createdAt: desc }) {
|
||||
id
|
||||
size
|
||||
createdAt
|
||||
completedAt
|
||||
...Backup
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
query GetBackupPresignedUrl(
|
||||
$appId: String!
|
||||
$backupId: String!
|
||||
$expireInMinutes: Int
|
||||
) {
|
||||
getBackupPresignedUrl: getBackupPresignedURL(
|
||||
appID: $appId
|
||||
backupID: $backupId
|
||||
expireInMinutes: $expireInMinutes
|
||||
) {
|
||||
url
|
||||
expiresAt: expires_at
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export default function StagingMetadata({
|
||||
return (
|
||||
isDevOrStaging() && (
|
||||
<div className="mx-auto mt-10 max-w-sm">
|
||||
<Box className="mx-auto flex flex-col rounded-md border p-5 text-center">
|
||||
<Box className="mx-auto grid grid-flow-row justify-items-center rounded-md border p-5 text-center">
|
||||
<Status status={StatusEnum.Deploying}>Internal info</Status>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Project, Workspace } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { GetWorkspaceAndProjectDocument } from '@/utils/__generated__/graphql';
|
||||
import { useNhostClient, useUserData } from '@nhost/nextjs';
|
||||
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||
import type { RefetchOptions } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -34,14 +34,24 @@ export interface UseCurrentWorkspaceAndProjectReturnType {
|
||||
|
||||
export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndProjectReturnType {
|
||||
const client = useNhostClient();
|
||||
const user = useUserData();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
|
||||
const {
|
||||
query: { workspaceSlug, appSlug },
|
||||
isReady,
|
||||
isReady: isRouterReady,
|
||||
} = useRouter();
|
||||
|
||||
const isWorkspaceSlugAvailable = Boolean(workspaceSlug);
|
||||
|
||||
const shouldFetchWorkspaceAndProject =
|
||||
isPlatform &&
|
||||
isRouterReady &&
|
||||
isWorkspaceSlugAvailable &&
|
||||
isAuthenticated &&
|
||||
!isAuthLoading;
|
||||
|
||||
// We can't use the hook exported by the codegen here because there are cases
|
||||
// where it doesn't target the Nhost backend, but the currently active project
|
||||
// instead.
|
||||
@@ -59,7 +69,7 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: isPlatform && isReady && !!workspaceSlug && !!user,
|
||||
enabled: shouldFetchWorkspaceAndProject,
|
||||
// multiple components are relying on this query, so we don't want to
|
||||
// refetch it too often - kind of a hack, should be improved later
|
||||
staleTime: 1000,
|
||||
@@ -142,7 +152,7 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
return {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
loading: response ? false : isFetching,
|
||||
loading: response ? false : isFetching || isAuthLoading,
|
||||
error: response?.error
|
||||
? new Error(error?.message || 'Unknown error occurred.')
|
||||
: null,
|
||||
|
||||
@@ -8,11 +8,13 @@ import { GraphQLIcon } from '@/components/ui/v2/icons/GraphQLIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { HomeIcon } from '@/components/ui/v2/icons/HomeIcon';
|
||||
import { RocketIcon } from '@/components/ui/v2/icons/RocketIcon';
|
||||
import { ServicesIcon } from '@/components/ui/v2/icons/ServicesIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
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 {
|
||||
@@ -56,8 +58,26 @@ export interface ProjectRoute {
|
||||
export default function useProjectRoutes() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, loading: currentProjectLoading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
loading: currentProjectLoading,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
const hypertune = useHypertune();
|
||||
|
||||
const enableServices =
|
||||
currentWorkspace &&
|
||||
hypertune
|
||||
.root({
|
||||
context: {
|
||||
workSpace: {
|
||||
id: currentWorkspace.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.enableServices({})
|
||||
.get(false);
|
||||
|
||||
const nhostRoutes: ProjectRoute[] = [
|
||||
{
|
||||
@@ -98,7 +118,7 @@ export default function useProjectRoutes() {
|
||||
},
|
||||
];
|
||||
|
||||
const allRoutes: ProjectRoute[] = [
|
||||
let allRoutes: ProjectRoute[] = [
|
||||
{
|
||||
relativePath: '/',
|
||||
exact: true,
|
||||
@@ -136,9 +156,19 @@ export default function useProjectRoutes() {
|
||||
label: 'Storage',
|
||||
icon: <StorageIcon />,
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
if (enableServices) {
|
||||
allRoutes.push({
|
||||
relativePath: '/services',
|
||||
exact: false,
|
||||
label: 'Run',
|
||||
icon: <ServicesIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
allRoutes = [...allRoutes, ...nhostRoutes];
|
||||
|
||||
return {
|
||||
nhostRoutes,
|
||||
allRoutes,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProjectFragment } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
getAuthServiceUrl,
|
||||
getDatabaseServiceUrl,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
getStorageServiceUrl,
|
||||
isPlatform,
|
||||
} from '@/utils/env';
|
||||
import type { ProjectFragment } from '@/utils/__generated__/graphql';
|
||||
|
||||
export type NhostService =
|
||||
| 'auth'
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="System Environment Variables"
|
||||
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
|
||||
description="System environment variables are automatically generated from the configuration file and your project's subdomain and region."
|
||||
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
|
||||
rootClassName="gap-0"
|
||||
className="mt-2 mb-2.5 px-0"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mockApplication, mockWorkspace } from '@/tests/mocks';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { queryClient, render, screen } from '@/tests/testUtils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
@@ -34,6 +35,7 @@ vi.mock('next/router', () => ({
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
tokenQuery,
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
),
|
||||
@@ -135,6 +137,7 @@ test('should render an empty state when GitHub is connected, but there are no de
|
||||
|
||||
test('should render a list of deployments', async () => {
|
||||
server.use(
|
||||
tokenQuery,
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
|
||||
@@ -194,6 +197,7 @@ test('should render a list of deployments', async () => {
|
||||
|
||||
test('should disable redeployments if a deployment is already in progress', async () => {
|
||||
server.use(
|
||||
tokenQuery,
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resourcesUpdatedQuery,
|
||||
} from '@/tests/msw/mocks/graphql/resourceSettingsQuery';
|
||||
import updateConfigMutation from '@/tests/msw/mocks/graphql/updateConfigMutation';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -35,6 +36,7 @@ vi.mock('next/router', () => ({
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
tokenQuery,
|
||||
resourcesAvailableQuery,
|
||||
getProPlanOnlyQuery,
|
||||
getWorkspaceAndProjectQuery,
|
||||
|
||||
@@ -48,6 +48,21 @@ export const MIN_SERVICE_VCPU = 0.25 * RESOURCE_VCPU_MULTIPLIER;
|
||||
*/
|
||||
export const MAX_SERVICE_VCPU = 7 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* Best resource utilization ration for CPU-Memory.
|
||||
*/
|
||||
export const MEM_CPU_RATIO = 2.048;
|
||||
|
||||
/**
|
||||
* Minimum storage capacity (Gib)
|
||||
*/
|
||||
export const MIN_STORAGE_CAPACITY = 1;
|
||||
|
||||
/**
|
||||
* Maximum storage capacity (Gib)
|
||||
*/
|
||||
export const MAX_STORAGE_CAPACITY = 1000;
|
||||
|
||||
/**
|
||||
* The minimum amount of memory that has to be allocated per service.
|
||||
*/
|
||||
@@ -135,3 +150,8 @@ export const resourceSettingsValidationSchema = Yup.object({
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
typeof resourceSettingsValidationSchema
|
||||
>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
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 { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
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 { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
MAX_SERVICE_REPLICAS,
|
||||
MAX_SERVICES_CPU,
|
||||
MAX_SERVICES_MEM,
|
||||
MIN_SERVICES_CPU,
|
||||
MIN_SERVICES_MEM,
|
||||
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { ComputeFormSection } from '@/features/services/components/ServiceForm/components/ComputeFormSection';
|
||||
import { EnvironmentFormSection } from '@/features/services/components/ServiceForm/components/EnvironmentFormSection';
|
||||
import { PortsFormSection } from '@/features/services/components/ServiceForm/components/PortsFormSection';
|
||||
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 {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { parse } from 'shell-quote';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export enum PortTypes {
|
||||
HTTP = 'http',
|
||||
TCP = 'tcp',
|
||||
UDP = 'udp',
|
||||
}
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
image: Yup.string().label('Image to run'),
|
||||
command: Yup.string().required(),
|
||||
environment: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
}),
|
||||
),
|
||||
compute: Yup.object({
|
||||
cpu: Yup.number().min(MIN_SERVICES_CPU).max(MAX_SERVICES_CPU).required(),
|
||||
memory: Yup.number().min(MIN_SERVICES_MEM).max(MAX_SERVICES_MEM).required(),
|
||||
}),
|
||||
replicas: Yup.number().min(1).max(MAX_SERVICE_REPLICAS).required(),
|
||||
ports: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
port: Yup.number().required(),
|
||||
type: Yup.mixed<PortTypes>().oneOf(Object.values(PortTypes)).required(),
|
||||
publish: Yup.boolean().default(false),
|
||||
}),
|
||||
),
|
||||
storage: Yup.array().of(
|
||||
Yup.object()
|
||||
.shape({
|
||||
name: Yup.string().required(),
|
||||
path: Yup.string().required(),
|
||||
capacity: Yup.number().nonNullable().required(),
|
||||
})
|
||||
.required(),
|
||||
),
|
||||
});
|
||||
|
||||
export type ServiceFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the service
|
||||
*/
|
||||
serviceID?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: ServiceFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the submit is successful.
|
||||
*/
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
}
|
||||
|
||||
export default function ServiceForm({
|
||||
serviceID,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: ServiceFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation();
|
||||
|
||||
const [createServiceFormError, setCreateServiceFormError] =
|
||||
useState<Error | null>(null);
|
||||
|
||||
const form = useForm<ServiceFormValues>({
|
||||
defaultValues: initialData ?? {
|
||||
compute: {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
watch,
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const serviceImage = watch('image');
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateService = async (values: ServiceFormValues) => {
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: values.name,
|
||||
image: {
|
||||
image: values.image,
|
||||
},
|
||||
command: parse(values.command).map((item) => item.toString()),
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: values.compute.cpu,
|
||||
memory: values.compute.memory,
|
||||
},
|
||||
storage: values.storage.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: values.replicas,
|
||||
},
|
||||
environment: values.environment.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
ports: values.ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
})),
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
// Update service config
|
||||
await replaceRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID,
|
||||
config,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
object: {
|
||||
appID: currentProject.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await insertRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID: newServiceID,
|
||||
config: {
|
||||
...config,
|
||||
image: {
|
||||
// If the image field left empty then we auto-populate following this format
|
||||
// registry.<region>.<nhost_domain>/<service_id>
|
||||
image:
|
||||
values.image.length > 0
|
||||
? values.image
|
||||
: `registry.${currentProject.region.awsName}.${currentProject.region.domain}/${newServiceID}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ServiceFormValues) => {
|
||||
try {
|
||||
await toast.promise(
|
||||
createOrUpdateService(values),
|
||||
{
|
||||
loading: 'Configuring the service...',
|
||||
success: `The service has been configured successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while configuring the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
// await refetchWorkspaceAndProject();
|
||||
// refestch the services
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 px-6 pb-6"
|
||||
>
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="Service name"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('image')}
|
||||
id="image"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Image</Text>
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="To automatically fill the private registry, leave it blank."
|
||||
hideEmptyHelperText
|
||||
error={!!errors.image}
|
||||
helperText={errors?.image?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* This shows only when trying to edit a service */}
|
||||
{serviceID && serviceImage && (
|
||||
<InfoCard
|
||||
title="Private registry"
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
{...register('command')}
|
||||
id="command"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Command</Text>
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="$ npm start"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.command}
|
||||
helperText={errors?.command?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<ComputeFormSection />
|
||||
|
||||
<ReplicasFormSection />
|
||||
|
||||
<EnvironmentFormSection />
|
||||
|
||||
<PortsFormSection />
|
||||
|
||||
<StorageFormSection />
|
||||
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setCreateServiceFormError(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{initialData ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowLeftIcon } from '@/components/ui/v2/icons/ArrowLeftIcon';
|
||||
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Slider } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import {
|
||||
MAX_SERVICES_MEM,
|
||||
MEM_CPU_RATIO,
|
||||
MIN_SERVICES_MEM,
|
||||
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ComputeFormSection() {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
|
||||
const handleSliderUpdate = (value: string) => {
|
||||
const updatedMem = parseFloat(value);
|
||||
|
||||
if (Number.isNaN(updatedMem) || updatedMem < MIN_SERVICES_MEM) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('compute.memory', Math.floor(updatedMem), { shouldDirty: true });
|
||||
setValue('compute.cpu', Math.floor(updatedMem / MEM_CPU_RATIO), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const incrementCompute = () => {
|
||||
const newMemoryValue = formValues.compute.memory + 128;
|
||||
setValue('compute.memory', newMemoryValue);
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO));
|
||||
};
|
||||
|
||||
const decrementCompute = () => {
|
||||
const newMemoryValue = formValues.compute.memory - 128;
|
||||
setValue('compute.memory', newMemoryValue);
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
CPU: {formValues.compute.cpu} / Memory: {formValues.compute.memory}
|
||||
</Text>
|
||||
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-row items-center justify-between space-x-4">
|
||||
<Button
|
||||
disabled={formValues.compute.memory <= MIN_SERVICES_MEM}
|
||||
variant="outlined"
|
||||
onClick={decrementCompute}
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Slider
|
||||
value={Number(formValues.compute.memory)}
|
||||
onChange={(_event, value) => handleSliderUpdate(value.toString())}
|
||||
max={MAX_SERVICES_MEM}
|
||||
min={MIN_SERVICES_MEM}
|
||||
step={256}
|
||||
aria-label="Compute resources"
|
||||
marks
|
||||
/>
|
||||
<Button
|
||||
disabled={formValues.compute.memory >= MAX_SERVICES_MEM}
|
||||
variant="outlined"
|
||||
onClick={incrementCompute}
|
||||
>
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ComputeFormSection } from './ComputeFormSection';
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function EnvironmentFormSection() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'environment',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Environment
|
||||
</Text>
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ name: '', value: '' })}
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
export { default as EnvironmentFormSection } from './EnvironmentFormSection';
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ControlledSwitch } from '@/components/form/ControlledSwitch';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
PortTypes,
|
||||
type ServiceFormValues,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function PortsFormSection() {
|
||||
const form = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'ports',
|
||||
});
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
|
||||
const onChangePortType = (value: string | undefined, index: number) =>
|
||||
setValue(`ports.${index}.type`, value as PortTypes);
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, _name: 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 (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Ports
|
||||
</Text>
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ port: null, type: null, publish: false })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box key={field.id} className="flex flex-col space-y-2">
|
||||
<Box className="flex w-full flex-col space-y-2 xs+:flex-row xs+:space-x-2 xs+:space-y-0">
|
||||
<Input
|
||||
{...register(`ports.${index}.port`)}
|
||||
id={`${field.id}-port`}
|
||||
placeholder="Port"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.ports?.at(index)}
|
||||
helperText={errors?.ports?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Select
|
||||
fullWidth
|
||||
value={formValues.ports.at(index)?.type || ''}
|
||||
onChange={(_event, inputValue) =>
|
||||
onChangePortType(inputValue as string, index)
|
||||
}
|
||||
placeholder="Select port type"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{['http', 'tcp', 'udp']?.map((portType) => (
|
||||
<Option key={portType} value={portType}>
|
||||
{portType}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ControlledSwitch
|
||||
{...register(`ports.${index}.publish`)}
|
||||
disabled={false} // TODO turn off and disable if the port is not http
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Publish
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showURL(index) && (
|
||||
<InfoCard
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.name,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
export { default as PortsFormSection } from './PortsFormSection';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Slider } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { MAX_SERVICE_REPLICAS } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ReplicasFormSection() {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { replicas } = useWatch<ServiceFormValues>();
|
||||
|
||||
const handleReplicasChange = (value: string) => {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
|
||||
setValue('replicas', updatedReplicas, { shouldDirty: true });
|
||||
|
||||
// TODO Trigger revalidate storage
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
Replicas ({replicas})
|
||||
</Text>
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Slider
|
||||
value={replicas}
|
||||
onChange={(_event, value) => handleReplicasChange(value.toString())}
|
||||
min={0}
|
||||
max={MAX_SERVICE_REPLICAS}
|
||||
step={1}
|
||||
aria-label="Replicas"
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ReplicasFormSection } from './ReplicasFormSection';
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import {
|
||||
MAX_STORAGE_CAPACITY,
|
||||
MIN_STORAGE_CAPACITY,
|
||||
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function StorageFormSection() {
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'storage',
|
||||
});
|
||||
|
||||
const checkBounds = (value: string, index: number) => {
|
||||
const storageCapacity = parseInt(value, 10);
|
||||
|
||||
if (Number.isNaN(storageCapacity)) {
|
||||
setValue(`storage.${index}.capacity`, 1);
|
||||
}
|
||||
|
||||
if (storageCapacity > MAX_STORAGE_CAPACITY) {
|
||||
setValue(`storage.${index}.capacity`, MAX_STORAGE_CAPACITY);
|
||||
}
|
||||
|
||||
if (storageCapacity < MIN_STORAGE_CAPACITY) {
|
||||
setValue(`storage.${index}.capacity`, MIN_STORAGE_CAPACITY);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Storage
|
||||
</Text>
|
||||
|
||||
<Tooltip title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s">
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ name: '', capacity: 1, path: '' })}
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<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(`storage.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label={!index && 'Name'}
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.storage?.at(index)}
|
||||
helperText={errors?.storage?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`storage.${index}.capacity`, {
|
||||
onBlur: (event) => checkBounds(event.target.value, index),
|
||||
})}
|
||||
id={`${field.id}-capacity`}
|
||||
label={!index && 'Capacity'}
|
||||
type="number"
|
||||
placeholder="Capacity"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.storage?.at(index)}
|
||||
helperText={errors?.storage?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
endAdornment={
|
||||
<Text sx={{ color: 'grey.500' }} className="pr-2">
|
||||
GiB
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`storage.${index}.path`)}
|
||||
id={`${field.id}-path`}
|
||||
label={!index && 'Path'}
|
||||
placeholder="Path"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.storage?.at(index)}
|
||||
helperText={errors?.storage?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as StorageFormSection } from './StorageFormSection';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceForm';
|
||||
export { default as ServiceForm } from './ServiceForm';
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { CubeIcon } from '@/components/ui/v2/icons/CubeIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
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 {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
*/
|
||||
services: RunService[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function ServicesList({
|
||||
services,
|
||||
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 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>
|
||||
</Box>
|
||||
),
|
||||
component: (
|
||||
<ServiceForm
|
||||
serviceID={service.id}
|
||||
initialData={{
|
||||
...service.config,
|
||||
image: image.image,
|
||||
command: command?.join(' '),
|
||||
ports: ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
})),
|
||||
compute,
|
||||
replicas,
|
||||
storage,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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(),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{services.map((service) => (
|
||||
<Box
|
||||
key={service.id}
|
||||
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||
sx={{
|
||||
[`&:hover`]: {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => viewService(service)}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{service.config.name}
|
||||
</Text>
|
||||
<Tooltip title={service.updatedAt}>
|
||||
<span className="hidden cursor-pointer text-sm text-slate-500 xs+:flex">
|
||||
Deployed {formatDistanceToNow(new Date(service.updatedAt))}{' '}
|
||||
ago
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{service.id}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
copy(service.id, 'Service Id');
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
aria-label="More options"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => viewService(service)}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Text className="font-medium">View Service</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteService(service.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete Service
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesList } from './ServicesList';
|
||||
@@ -1,3 +0,0 @@
|
||||
mutation resetPostgresPassword($appID: String!, $newPassword: String!) {
|
||||
resetPostgresPassword(appID: $appID, newPassword: $newPassword)
|
||||
}
|
||||
5
dashboard/src/gql/services/deleteRunService.graphql
Normal file
5
dashboard/src/gql/services/deleteRunService.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation deleteRunService($serviceID: uuid!) {
|
||||
deleteRunService(id: $serviceID) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteRunServiceConfig($appID: uuid!, $serviceID: uuid!) {
|
||||
deleteRunServiceConfig(appID: $appID, serviceID: $serviceID) {
|
||||
name
|
||||
}
|
||||
}
|
||||
33
dashboard/src/gql/services/getRunService.graphql
Normal file
33
dashboard/src/gql/services/getRunService.graphql
Normal file
@@ -0,0 +1,33 @@
|
||||
query getRunService($id: uuid!, $resolve: Boolean!) {
|
||||
runService(id: $id) {
|
||||
id
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
image
|
||||
}
|
||||
command
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
storage {
|
||||
name
|
||||
path
|
||||
capacity
|
||||
}
|
||||
replicas
|
||||
}
|
||||
environment {
|
||||
name
|
||||
value
|
||||
}
|
||||
ports {
|
||||
port
|
||||
type
|
||||
publish
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
dashboard/src/gql/services/getRunServices.graphql
Normal file
48
dashboard/src/gql/services/getRunServices.graphql
Normal file
@@ -0,0 +1,48 @@
|
||||
query getRunServices(
|
||||
$appID: uuid!
|
||||
$resolve: Boolean!
|
||||
$limit: Int!
|
||||
$offset: Int!
|
||||
) {
|
||||
app(id: $appID) {
|
||||
runServices(limit: $limit, offset: $offset) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
image
|
||||
}
|
||||
command
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
storage {
|
||||
name
|
||||
path
|
||||
capacity
|
||||
}
|
||||
replicas
|
||||
}
|
||||
environment {
|
||||
name
|
||||
value
|
||||
}
|
||||
ports {
|
||||
port
|
||||
type
|
||||
publish
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runServices_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
dashboard/src/gql/services/inserRunService.graphql
Normal file
6
dashboard/src/gql/services/inserRunService.graphql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation insertRunService($object: run_service_insert_input!) {
|
||||
insertRunService(object: $object) {
|
||||
id
|
||||
appID
|
||||
}
|
||||
}
|
||||
13
dashboard/src/gql/services/insertRunServiceConfig.graphql
Normal file
13
dashboard/src/gql/services/insertRunServiceConfig.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation insertRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigInsertInput!
|
||||
) {
|
||||
insertRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
name
|
||||
}
|
||||
}
|
||||
13
dashboard/src/gql/services/replaceRunServiceConfig.graphql
Normal file
13
dashboard/src/gql/services/replaceRunServiceConfig.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation replaceRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigInsertInput!
|
||||
) {
|
||||
replaceRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
13
dashboard/src/gql/services/updateRunServiceConfig.graphql
Normal file
13
dashboard/src/gql/services/updateRunServiceConfig.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation updateRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigUpdateInput!
|
||||
) {
|
||||
updateRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
name
|
||||
}
|
||||
}
|
||||
1
dashboard/src/hooks/useHypertune/index.ts
Normal file
1
dashboard/src/hooks/useHypertune/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useHypertune } from './useHypertune';
|
||||
14
dashboard/src/hooks/useHypertune/useHypertune.ts
Normal file
14
dashboard/src/hooks/useHypertune/useHypertune.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import hypertune from '@/hypertune/hypertune';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useHypertune() {
|
||||
const [, setIsInitialized] = useState<boolean>(hypertune.isInitialized());
|
||||
|
||||
useEffect(() => {
|
||||
hypertune.waitForInitialization().then(() => {
|
||||
setIsInitialized(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return hypertune;
|
||||
}
|
||||
2
dashboard/src/hooks/useLeaveConfirm/index.ts
Normal file
2
dashboard/src/hooks/useLeaveConfirm/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useLeaveConfirm';
|
||||
export { default as useLeaveConfirm } from './useLeaveConfirm';
|
||||
48
dashboard/src/hooks/useLeaveConfirm/useLeaveConfirm.ts
Normal file
48
dashboard/src/hooks/useLeaveConfirm/useLeaveConfirm.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface UseLeaveConfirmProps {
|
||||
/**
|
||||
* Whether the form is dirty or not.
|
||||
*/
|
||||
isDirty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook will show a confirmation dialog when the user tries to leave the
|
||||
* current page while the form is dirty.
|
||||
*/
|
||||
export default function useLeaveConfirm({ isDirty }: UseLeaveConfirmProps) {
|
||||
const router = useRouter();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [isConfirmed, setConfirmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onRouteChangeStart(route: string) {
|
||||
if (!isDirty || isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
openAlertDialog({
|
||||
title: 'Unsaved changes',
|
||||
payload:
|
||||
'You have unsaved local changes. Are you sure you want to discard them?',
|
||||
props: {
|
||||
primaryButtonColor: 'error',
|
||||
primaryButtonText: 'Discard',
|
||||
onPrimaryAction: () => {
|
||||
setConfirmed(true);
|
||||
router.push(route);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error('Route change aborted');
|
||||
}
|
||||
|
||||
router.events.on('routeChangeStart', onRouteChangeStart);
|
||||
|
||||
return () => router.events.off('routeChangeStart', onRouteChangeStart);
|
||||
}, [isConfirmed, isDirty, openAlertDialog, router, router.events]);
|
||||
}
|
||||
5
dashboard/src/hypertune/hypertune.ts
Normal file
5
dashboard/src/hypertune/hypertune.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { initializeHypertune } from './project_2596';
|
||||
|
||||
const hypertune = initializeHypertune({});
|
||||
|
||||
export default hypertune;
|
||||
107
dashboard/src/hypertune/project_2596.ts
Normal file
107
dashboard/src/hypertune/project_2596.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import * as sdk from "hypertune";
|
||||
|
||||
const projectId = 2596;
|
||||
|
||||
const businessToken = `U2FsdGVkX19+V8BJnVR0xLEC+42OW5qZl/A0i6beAaRmJoIhFh5Yf6eIKBzLbV9h`;
|
||||
|
||||
const queryCode = `query InitQuery {
|
||||
root {
|
||||
enableServices
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const query = {"Query":{"objectTypeName":"Query","selection":{"root":{"fieldArguments":{"__isPartialObject__":true},"fieldQuery":{"Root":{"objectTypeName":"Root","selection":{"enableServices":{"fieldArguments":{},"fieldQuery":null}}}}}}}};
|
||||
|
||||
const fallbackInitData: sdk.FallbackInitData & { [key: string]: unknown } = {"commitId":3297,"reducedExpression":{"id":"caxyeQqTKX3UGOXClvbnW","logs":{"events":{},"exposures":{},"evaluations":{}},"type":"ObjectExpression","fields":{"root":{"id":"PoMWxsy7KbW9fCq5XXvx4","body":{"id":"IUICRjZ7iSnh9k0cWBmnd","logs":{"events":{},"exposures":{},"evaluations":{}},"type":"ObjectExpression","fields":{"enableServices":{"id":"7WZWy2AIy_q9Vbz4cn9KB","logs":{"evaluations":{"XNOtHkUBpglrY1nkYa_bf":1},"events":{},"exposures":{}},"type":"BooleanExpression","value":true,"valueType":{"type":"BooleanValueType"}}},"valueType":{"type":"ObjectValueType","objectTypeName":"Root"},"objectTypeName":"Root"},"logs":{"events":{},"exposures":{},"evaluations":{}},"type":"FunctionExpression","valueType":{"type":"FunctionValueType","returnValueType":{"type":"ObjectValueType","objectTypeName":"Root"},"parameterValueTypes":[{"type":"ObjectValueType","objectTypeName":"Query_root_args"}]},"parameters":[{"id":"Ygjhl2LqjiwcousTABFQz","name":"rootArgs"}]}},"metadata":{"permissions":{"user":{},"group":{"team":{"write":"allow"}}}},"valueType":{"type":"ObjectValueType","objectTypeName":"Query"},"objectTypeName":"Query"},"splits":{},"eventTypes":{},"commitConfig":{"splitConfig":{}},"initLogId":0,"commitHash":"4178461588049503","sdkConfig":{"hashPollInterval":1000,"flushLogsInterval":1000,"maxLogsPerFlush":1},"query":{"Query":{"objectTypeName":"Query","selection":{"root":{"fieldArguments":{"__isPartialObject__":true},"fieldQuery":{"Root":{"objectTypeName":"Root","selection":{"enableServices":{"fieldArguments":{},"fieldQuery":null}}}}}}}}};
|
||||
|
||||
export function initializeHypertune(
|
||||
variableValues: Rec,
|
||||
options: sdk.InitializeOptions = {}
|
||||
): QueryNode {
|
||||
const defaultOptions = { businessToken, query, fallbackInitData };
|
||||
|
||||
return sdk.initialize(
|
||||
QueryNode,
|
||||
projectId,
|
||||
queryCode,
|
||||
variableValues,
|
||||
{ ...defaultOptions, ...options }
|
||||
);
|
||||
}
|
||||
|
||||
// Enum types
|
||||
|
||||
|
||||
|
||||
// Input object types
|
||||
|
||||
export type Rec = {
|
||||
|
||||
//
|
||||
};
|
||||
|
||||
export type Rec2 = {
|
||||
context: Rec3;
|
||||
//
|
||||
};
|
||||
|
||||
export type Rec3 = {
|
||||
workSpace: Rec4;
|
||||
//
|
||||
};
|
||||
|
||||
export type Rec4 = {
|
||||
id: string;
|
||||
//
|
||||
};
|
||||
|
||||
// Enum node classes
|
||||
|
||||
|
||||
|
||||
// Fragment node classes
|
||||
|
||||
export class QueryNode extends sdk.Node {
|
||||
typeName = "Query" as const;
|
||||
|
||||
root(args: Rec2): RootNode {
|
||||
const props0 = this.getField("root", args);
|
||||
const expression0 = props0.expression;
|
||||
|
||||
if (
|
||||
expression0 &&
|
||||
expression0.type === "ObjectExpression"
|
||||
&& expression0.objectTypeName === "Root"
|
||||
) {
|
||||
return new RootNode(props0);
|
||||
}
|
||||
|
||||
const node = new RootNode(props0);
|
||||
node._logUnexpectedTypeError();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
export class RootNode extends sdk.Node {
|
||||
typeName = "Root" as const;
|
||||
|
||||
enableServices(args: Rec): sdk.BooleanNode {
|
||||
const props0 = this.getField("enableServices", args);
|
||||
const expression0 = props0.expression;
|
||||
|
||||
if (
|
||||
expression0 &&
|
||||
expression0.type === "BooleanExpression"
|
||||
|
||||
) {
|
||||
return new sdk.BooleanNode(props0);
|
||||
}
|
||||
|
||||
const node = new sdk.BooleanNode(props0);
|
||||
node._logUnexpectedTypeError();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,13 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Chip } from '@/components/ui/v2/Chip';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { RestoreBackupModal } from '@/features/projects/backups/components/RestoreBackupModal';
|
||||
import { BackupList } from '@/features/projects/backups/components/BackupList';
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useGetApplicationBackupsQuery } from '@/generated/graphql';
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { formatDistanceStrict, formatISO9075 } from 'date-fns';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type Backup = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
function BackupsHeader() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { plan } = currentProject;
|
||||
return (
|
||||
<div className="flex flex-row place-content-between">
|
||||
<div>
|
||||
<Text className="text-2xl font-medium" variant="h1">
|
||||
Backups
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Chip
|
||||
color={plan.isFree ? 'default' : 'success'}
|
||||
label={plan.isFree ? 'Off' : 'Live'}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupRow({ backup }: any) {
|
||||
const { id, createdAt, size } = backup;
|
||||
|
||||
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{restoreModalOpen && (
|
||||
<Modal
|
||||
showModal={restoreModalOpen}
|
||||
close={() => setRestoreModalOpen(false)}
|
||||
Component={RestoreBackupModal}
|
||||
data={{ id, createdAt }}
|
||||
/>
|
||||
)}
|
||||
<Box className="flex flex-row place-content-between py-3">
|
||||
<Text className="w-drop self-center text-xs font-medium">
|
||||
{formatISO9075(new Date(createdAt))}
|
||||
</Text>
|
||||
<Text className="w-drop self-center text-xs font-medium">
|
||||
{prettifySize(size)}
|
||||
</Text>
|
||||
<Text className="w-drop self-center text-xs font-medium">
|
||||
{formatDistanceStrict(new Date(createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
<div className="w-20">
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => setRestoreModalOpen(true)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupsTable() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { data, loading, error } = useGetApplicationBackupsQuery({
|
||||
variables: { appId: currentProject.id },
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
className="my-5"
|
||||
label="Loading backups..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { backups } = data.app;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className="flex flex-row place-content-between border-b-1 py-2">
|
||||
<Text className="w-drop text-xs font-bold">Backup</Text>
|
||||
<Text className="w-drop text-xs font-bold">Size</Text>
|
||||
<Text className="w-drop text-xs font-bold">Backed Up</Text>
|
||||
<div className="w-20" />
|
||||
</Box>
|
||||
<Box className="border-b-1">
|
||||
{backups.length === 0 ? (
|
||||
<div className="flex flex-row px-1 py-4">
|
||||
<Text className="text-xs">No backups yet.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide divide-y-1">
|
||||
{backups.map((backup) => (
|
||||
<BackupRow key={backup.id} backup={backup} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionContainer({ title }: any) {
|
||||
return (
|
||||
<div className="mt-6 w-full">
|
||||
<Text className="text-lg font-medium">{title}</Text>
|
||||
<Text className="font-normal">
|
||||
The database backup includes database schema, database data and Hasura
|
||||
metadata. It does not include the actual files in Storage.
|
||||
</Text>
|
||||
|
||||
<div className="mt-6">
|
||||
<BackupsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupsContent() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -159,13 +22,43 @@ function BackupsContent() {
|
||||
);
|
||||
}
|
||||
|
||||
return <SectionContainer title="Database" />;
|
||||
return (
|
||||
<div className="mt-6 grid w-full grid-flow-row gap-6">
|
||||
<div>
|
||||
<Text className="font-medium">Database</Text>
|
||||
<Text color="secondary">
|
||||
The database backup includes database schema, database data and Hasura
|
||||
metadata. It does not include the actual files in Storage.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<BackupList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const { currentProject, loading } = useCurrentWorkspaceAndProject();
|
||||
const { plan } = currentProject;
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator label="Loading project..." delay={1000} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="max-w-2.5xl">
|
||||
<BackupsHeader />
|
||||
<div className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="text-2xl font-medium" variant="h1">
|
||||
Backups
|
||||
</Text>
|
||||
|
||||
<Chip
|
||||
color={plan.isFree ? 'default' : 'success'}
|
||||
label={plan.isFree ? 'Off' : 'Live'}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<BackupsContent />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
190
dashboard/src/pages/[workspaceSlug]/[appSlug]/services/index.tsx
Normal file
190
dashboard/src/pages/[workspaceSlug]/[appSlug]/services/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CubeIcon } from '@/components/ui/v2/icons/CubeIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { ServicesIcon } from '@/components/ui/v2/icons/ServicesIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
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 ServicesList from '@/features/services/components/ServicesList/ServicesList';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
|
||||
|
||||
export type RunService = Omit<
|
||||
GetRunServicesQuery['app']['runServices'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function ServicesPage() {
|
||||
const limit = useRef(25);
|
||||
const router = useRouter();
|
||||
const { openDrawer } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlanFree = currentProject.plan.isFree;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
parseInt(router.query.page as string, 10) || 1,
|
||||
);
|
||||
|
||||
const [nrOfPages, setNrOfPages] = useState(0);
|
||||
|
||||
const offset = useMemo(() => currentPage - 1, [currentPage]);
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
refetch: refetchServices,
|
||||
} = useGetRunServicesQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
resolve: false,
|
||||
limit: limit.current,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userCount = data?.app?.runServices_aggregate.aggregate.count ?? 0;
|
||||
|
||||
setNrOfPages(Math.ceil(userCount / limit.current));
|
||||
}, [data, loading]);
|
||||
|
||||
const services = useMemo(
|
||||
() => data?.app?.runServices.map((service) => service) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const openCreateServiceDialog = () => {
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Create a new service</Text>
|
||||
</Box>
|
||||
),
|
||||
component: <ServiceForm onSubmit={refetchServices} />,
|
||||
});
|
||||
};
|
||||
|
||||
if (isPlanFree) {
|
||||
return (
|
||||
<Container>
|
||||
<UpgradeNotification
|
||||
message="Unlock Nhost Run by upgrading your project to the Pro plan."
|
||||
className="mt-4"
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.app.runServices.length === 0 && !loading) {
|
||||
return (
|
||||
<Container className="mx-auto max-w-9xl space-y-5 overflow-x-hidden">
|
||||
<div className="flex flex-row place-content-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 rounded-lg border px-48 py-12 shadow-sm">
|
||||
<ServicesIcon className="h-10 w-10" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No custom services are available
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
All your project’s custom services will be listed here.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between rounded-lg ">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Box className="flex flex-row place-content-end border-b-1 p-4">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className="space-y-4">
|
||||
<ServicesList
|
||||
services={services}
|
||||
onDelete={() => refetchServices()}
|
||||
onCreateOrUpdate={() => refetchServices()}
|
||||
/>
|
||||
<Pagination
|
||||
className="px-2"
|
||||
totalNrOfPages={nrOfPages}
|
||||
currentPageNumber={currentPage}
|
||||
totalNrOfElements={
|
||||
data?.app?.runServices_aggregate.aggregate.count ?? 0
|
||||
}
|
||||
itemsLabel="services"
|
||||
elementsPerPage={limit.current}
|
||||
onPrevPageClick={async () => {
|
||||
setCurrentPage((page) => page - 1);
|
||||
if (currentPage - 1 !== 1) {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, page: currentPage - 1 },
|
||||
});
|
||||
}
|
||||
}}
|
||||
onNextPageClick={async () => {
|
||||
setCurrentPage((page) => page + 1);
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, page: currentPage + 1 },
|
||||
});
|
||||
}}
|
||||
onPageChange={async (page) => {
|
||||
setCurrentPage(page);
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, page },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
};
|
||||
@@ -347,6 +347,7 @@ export default function UsersPage() {
|
||||
.count
|
||||
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count
|
||||
}
|
||||
itemsLabel="users"
|
||||
elementsPerPage={
|
||||
searchString
|
||||
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate
|
||||
|
||||
@@ -6,10 +6,7 @@ import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Radio } from '@/components/ui/v2/Radio';
|
||||
import { RadioGroup } from '@/components/ui/v2/RadioGroup';
|
||||
@@ -17,18 +14,12 @@ import { Select } from '@/components/ui/v2/Select';
|
||||
import type { TextProps } from '@/components/ui/v2/Text';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import features from '@/data/features.json';
|
||||
import { generateRandomDatabasePassword } from '@/features/database/common/utils/generateRandomDatabasePassword';
|
||||
import { resetDatabasePasswordValidationSchema } from '@/features/database/settings/utils/resetDatabasePasswordValidationSchema';
|
||||
import { planDescriptions } from '@/features/projects/common/utils/planDescriptions';
|
||||
import { BillingPaymentMethodForm } from '@/features/projects/workspaces/components/BillingPaymentMethodForm';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
import { getCurrentEnvironment } from '@/utils/helpers';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type {
|
||||
PrefetchNewAppPlansFragment,
|
||||
PrefetchNewAppRegionsFragment,
|
||||
@@ -73,7 +64,6 @@ export function NewProjectPageContent({
|
||||
|
||||
// form
|
||||
const [name, setName] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
const [selectedWorkspace, setSelectedWorkspace] = useState({
|
||||
id: preSelectedWorkspace.id,
|
||||
@@ -89,10 +79,6 @@ export function NewProjectPageContent({
|
||||
code: preSelectedRegion.country.code,
|
||||
});
|
||||
|
||||
const [databasePassword, setDatabasePassword] = useState(
|
||||
generateRandomDatabasePassword(),
|
||||
);
|
||||
|
||||
// find the first acceptable plan as default plan
|
||||
const defaultSelectedPlan = plans.find((plan) => {
|
||||
if (!plan.isFree) {
|
||||
@@ -131,18 +117,6 @@ export function NewProjectPageContent({
|
||||
(availableWorkspace) => availableWorkspace.id === selectedWorkspace.id,
|
||||
);
|
||||
|
||||
const isK8SPostgresEnabledInCurrentEnvironment = features[
|
||||
'k8s-postgres'
|
||||
].enabled.find((e) => e === getCurrentEnvironment());
|
||||
|
||||
// function handlers
|
||||
const handleGenerateRandomPassword = () => {
|
||||
const newRandomDatabasePassword = generateRandomDatabasePassword();
|
||||
setPasswordError('');
|
||||
triggerToast('New random database password generated.');
|
||||
setDatabasePassword(newRandomDatabasePassword);
|
||||
};
|
||||
|
||||
async function handleCreateProject(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -159,19 +133,6 @@ export function NewProjectPageContent({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isK8SPostgresEnabledInCurrentEnvironment) {
|
||||
try {
|
||||
await resetDatabasePasswordValidationSchema.validate({
|
||||
databasePassword,
|
||||
});
|
||||
} catch (validationError) {
|
||||
setSubmitState({
|
||||
error: Error(validationError.errors),
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const slug = slugify(name, { lower: true, strict: true });
|
||||
|
||||
try {
|
||||
@@ -184,9 +145,6 @@ export function NewProjectPageContent({
|
||||
planId: plan.id,
|
||||
workspaceId: selectedWorkspace.id,
|
||||
regionId: selectedRegion.id,
|
||||
postgresPassword: isK8SPostgresEnabledInCurrentEnvironment
|
||||
? databasePassword
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -343,86 +301,6 @@ export function NewProjectPageContent({
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{isK8SPostgresEnabledInCurrentEnvironment && (
|
||||
<Input
|
||||
name="databasePassword"
|
||||
id="databasePassword"
|
||||
autoComplete="new-password"
|
||||
label="Database Password"
|
||||
value={databasePassword}
|
||||
variant="inline"
|
||||
type="password"
|
||||
error={!!passwordError}
|
||||
hideEmptyHelperText
|
||||
endAdornment={
|
||||
<InputAdornment position="end" className="mr-2">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
copy(databasePassword, 'Postgres password');
|
||||
}}
|
||||
variant="borderless"
|
||||
aria-label="Copy password"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
slotProps={{
|
||||
// Note: this is supposed to fix a `validateDOMNesting` error
|
||||
helperText: { component: 'div' },
|
||||
}}
|
||||
helperText={
|
||||
<div className="grid max-w-xs grid-flow-row gap-2">
|
||||
{passwordError && (
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
`${theme.palette.error.main} !important`,
|
||||
}}
|
||||
>
|
||||
{passwordError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="font-medium">
|
||||
The root Postgres password for your database - it must be
|
||||
strong and hard to guess.{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={handleGenerateRandomPassword}
|
||||
className="px-1 py-0.5 text-xs underline underline-offset-2 hover:underline"
|
||||
tabIndex={-1}
|
||||
>
|
||||
Generate a password
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
}
|
||||
onChange={async (e) => {
|
||||
e.preventDefault();
|
||||
setSubmitState({
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
setDatabasePassword(e.target.value);
|
||||
|
||||
try {
|
||||
await resetDatabasePasswordValidationSchema.validate({
|
||||
databasePassword: e.target.value,
|
||||
});
|
||||
setPasswordError('');
|
||||
} catch (validationError) {
|
||||
setPasswordError(validationError.message);
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
id="region"
|
||||
label="Region"
|
||||
@@ -499,7 +377,7 @@ export function NewProjectPageContent({
|
||||
<div className="grid w-full grid-cols-8 gap-x-4 gap-y-2">
|
||||
<div className="col-span-8 sm:col-span-2">
|
||||
<Text className="text-xs font-medium">Plan</Text>
|
||||
<Text variant="subtitle2">You can change this later.</Text>
|
||||
<Text variant="subtitle2">You can change this later</Text>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
@@ -584,7 +462,7 @@ export function NewProjectPageContent({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={submitState.loading}
|
||||
disabled={!!passwordError || maintenanceActive}
|
||||
disabled={maintenanceActive}
|
||||
id="create-app"
|
||||
>
|
||||
Create Project
|
||||
|
||||
@@ -88,6 +88,7 @@ export const mockWorkspace: Workspace = {
|
||||
slug: 'test-workspace',
|
||||
workspaceMembers: [],
|
||||
projects: [mockApplication],
|
||||
creatorUserId: '1',
|
||||
};
|
||||
|
||||
export const mockSession: NhostSession = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
const hasuraMetadataQuery = rest.post(
|
||||
'http://localhost:8080/v1/metadata',
|
||||
'https://local.hasura.nhost.run/v1/metadata',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(250),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
const tableQuery = rest.post(
|
||||
'http://localhost:8080/v2/query',
|
||||
'https://local.hasura.nhost.run/v2/query',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
|
||||
|
||||
32
dashboard/src/tests/msw/mocks/rest/tokenQuery.ts
Normal file
32
dashboard/src/tests/msw/mocks/rest/tokenQuery.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { NhostSession } from '@nhost/nextjs';
|
||||
import { rest } from 'msw';
|
||||
|
||||
const tokenQuery = rest.post(
|
||||
`https://local.auth.nhost.run/v1/token`,
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json<NhostSession>({
|
||||
accessToken: faker.datatype.string(40),
|
||||
refreshToken: faker.datatype.uuid(),
|
||||
accessTokenExpiresIn: 900,
|
||||
user: {
|
||||
id: faker.datatype.uuid(),
|
||||
createdAt: faker.date.past().toUTCString(),
|
||||
displayName: `${faker.name.firstName()} ${faker.name.lastName()}`,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
locale: 'en',
|
||||
isAnonymous: false,
|
||||
emailVerified: true,
|
||||
defaultRole: 'user',
|
||||
roles: ['user', 'me'],
|
||||
phoneNumber: null,
|
||||
phoneNumberVerified: false,
|
||||
activeMfaType: null,
|
||||
metadata: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export default tokenQuery;
|
||||
@@ -32,13 +32,13 @@ process.env = {
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||
NEXT_PUBLIC_ENV: 'dev',
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL: 'https://localdev.nhost.run/v1/auth',
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL: 'https://localdev.nhost.run/v1/functions',
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL: 'https://localdev.nhost.run/v1/graphql',
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL: 'https://localdev.nhost.run/v1/storage',
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL: 'http://localhost:9695',
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL: 'http://localhost:9693',
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'http://localhost:8080',
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL: 'https://local.auth.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL: 'https://local.functions.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL: 'https://local.graphql.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL: 'https://local.storage.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'https://local.hasura.nhost.run',
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AppStateHistoryFragment,
|
||||
BackupFragment,
|
||||
DeploymentRowFragment,
|
||||
EnvironmentVariableFragment,
|
||||
PermissionVariableFragment,
|
||||
@@ -39,6 +40,7 @@ export type ApplicationState = AppStateHistoryFragment;
|
||||
export type Deployment = DeploymentRowFragment;
|
||||
export type Workspace = WorkspaceFragment;
|
||||
export type Project = ProjectFragment;
|
||||
export type Backup = BackupFragment;
|
||||
|
||||
export interface PermissionVariable extends PermissionVariableFragment {
|
||||
isSystemVariable?: boolean;
|
||||
|
||||
1361
dashboard/src/utils/__generated__/graphql.ts
generated
1361
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
|
||||
export default function copy(toCopy: string, name: string) {
|
||||
export default function copy(toCopy: string, name?: string) {
|
||||
navigator.clipboard.writeText(toCopy).catch(() => {
|
||||
triggerToast('Error while copying');
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerToast(`${name} copied to clipboard`);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user