Compare commits

..

5 Commits

Author SHA1 Message Date
github-actions[bot]
551298b568 chore: update versions (#2900)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @nhost/dashboard@1.30.0

### Minor Changes

- 50441a8: feat: add ui for project autoscaler settings and run services
autoscaler settings

## @nhost/docs@2.18.0

### Minor Changes

-   c4aa159: feat: added advanced TLS document

### Patch Changes

-   91f0465: feat: added turnstile guide

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-27 15:01:34 +01:00
David BM
50441a84cb feat (dashboard): add autoscaler ui (#2872)
### **User description**
Resolves #2854


___

### **PR Type**
Enhancement


___

### **Description**
This PR introduces autoscaler UI functionality to the dashboard:

- Added new AutoscalerFormSection component for configuring autoscaler
settings
- Integrated autoscaler settings into ResourcesForm and ServiceForm
components
- Updated GraphQL queries and fragments to include autoscaler fields
- Modified validation schemas to accommodate autoscaler configurations
- Added new InfoOutlinedIcon component for improved UI feedback
- Updated types in graphql.ts to support new autoscaler and Grafana
features
- Implemented debounced handlers for form inputs to improve performance
- Added changeset for the new feature
- Made minor styling adjustments for consistency across components


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Documentation</strong></td><td><details><summary>2
files</summary><table>
<tr>
  <td>
    <details>
<summary><strong>next-env.d.ts</strong><dd><code>Update TypeScript
configuration URL</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/next-env.d.ts

- Updated the URL for TypeScript configuration information



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-987cb886800df332c01f9cc3bdda1d903e619d92610bfdf08be35525bb280cd2">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>thirty-ravens-applaud.md</strong><dd><code>Add
changeset for autoscaler UI</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></summary>
<hr>

.changeset/thirty-ravens-applaud.md

- Added changeset for the new autoscaler UI feature



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-f362e0efe6fe43cacc0b43340eb2a27bd9b49091536e45a22500e8752c5ea2aa">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

</table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>14
files</summary><table>
<tr>
  <td>
    <details>
<summary><strong>InfoOutlinedIcon.tsx</strong><dd><code>Add
InfoOutlinedIcon component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></summary>
<hr>


dashboard/src/components/ui/v2/icons/InfoOutlinedIcon/InfoOutlinedIcon.tsx

<li>Added a new InfoOutlinedIcon component<br> <li> Implemented the icon
using SVG paths<br> <li> Set up proper component naming and export<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-14bd3b1512123ee2900b3aacbcceb35cba9d43dc1cd847f9c7188519234a49cb">+30/-0</a>&nbsp;
&nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>index.ts</strong><dd><code>Export InfoOutlinedIcon
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/components/ui/v2/icons/InfoOutlinedIcon/index.ts

- Added export for InfoOutlinedIcon component



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-fbaf8132a386acdf834271af1efecd14f60b07f30b9906a710a6fee73e007da5">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>ResourcesForm.tsx</strong><dd><code>Integrate
autoscaler settings in ResourcesForm</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>


dashboard/src/features/projects/resources/settings/components/ResourcesForm/ResourcesForm.tsx

<li>Added autoscaler configuration to resource settings<br> <li> Updated
form initialization and submission to include autoscaler
<br>settings<br> <li> Modified form reset to include autoscaler
fields<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-6d00a7b503dbd4b76f86d3949458d7f0bd62622cf17c523e0d668e3b459b67b5">+64/-26</a>&nbsp;
</td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>ServiceResourcesFormFragment.tsx</strong><dd><code>Add
autoscaler UI to ServiceResourcesFormFragment</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>


dashboard/src/features/projects/resources/settings/components/ServiceResourcesFormFragment/ServiceResourcesFormFragment.tsx

<li>Added autoscaler UI elements including switch and max replicas
input<br> <li> Implemented debounced handlers for replica and max
replica changes<br> <li> Updated layout to accommodate new autoscaler
settings<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-101690b5bda069581f2bf13bfd9559484984f0c137349daff49c3901b8235fb3">+89/-45</a>&nbsp;
</td>

</tr>                    

<tr>
  <td>
    <details>

<summary><strong>resourceSettingsValidationSchema.ts</strong><dd><code>Update
validation schema for autoscaler</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>


dashboard/src/features/projects/resources/settings/utils/resourceSettingsValidationSchema/resourceSettingsValidationSchema.ts

<li>Updated validation schema to include autoscaler settings<br> <li>
Modified ratio validation to consider autoscaler activation<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-12c309ecfbe9c5393770e8eb678047701de3e7e0813972266014fa10a2add287">+8/-2</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>ServiceForm.tsx</strong><dd><code>Integrate autoscaler
in ServiceForm</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/features/services/components/ServiceForm/ServiceForm.tsx

<li>Added autoscaler configuration to service form submission<br> <li>
Included AutoscalerFormSection component in the form<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-d62640c5c152c7b50a3a53deefcb29c6ed1fa685e15511863c09784497139c49">+8/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>ServiceFormTypes.ts</strong><dd><code>Add autoscaler to
ServiceFormTypes</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>


dashboard/src/features/services/components/ServiceForm/ServiceFormTypes.ts

<li>Added autoscaler field to the validation schema<br> <li> Set up
validation rules for autoscaler maxReplicas<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-70dc64b40f78adad0ce3db0f56cddfe824f3eb2d116b2ea6411518546810f3af">+6/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>AutoscalerFormSection.tsx</strong><dd><code>Create
AutoscalerFormSection component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>


dashboard/src/features/services/components/ServiceForm/components/AutoscalerFormSection/AutoscalerFormSection.tsx

<li>Implemented new AutoscalerFormSection component<br> <li> Added UI
for enabling/disabling autoscaler and setting max replicas<br> <li>
Integrated with form context for data management<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-9c4cd6fb8cee6545ad4bba24cc91660a00d5d951315c9580f57c18f8d46be696">+87/-0</a>&nbsp;
&nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>index.ts</strong><dd><code>Export AutoscalerFormSection
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></summary>
<hr>


dashboard/src/features/services/components/ServiceForm/components/AutoscalerFormSection/index.ts

- Added export for AutoscalerFormSection component



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-e5e0fc7b69cda7ec41d221c2d2eb31236da9f75940cd245f9bf5edf8a7438c38">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>ServicesList.tsx</strong><dd><code>Include autoscaler
in ServicesList</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/features/services/components/ServicesList/ServicesList.tsx

<li>Added autoscaler field to service configuration<br> <li> Updated
styling classes for consistency<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-efb3008c23436b2db5bb94de15e91c78cf76ef6481ecb02eb542cf660ba98653">+9/-8</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>graphql.ts</strong><dd><code>Update GraphQL types for
autoscaler and Grafana</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/utils/__generated__/graphql.ts

<li>Updated GraphQL types to include autoscaler configurations<br> <li>
Added new types for Grafana alerting and contacts<br> <li> Modified
existing types to accommodate autoscaler fields<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-fbd5db84b560b1c91675004448c6c7fa0dcbfb28b9eb05d53b03e6cb7b83ebac">+293/-6</a>&nbsp;
</td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>getResources.gql</strong><dd><code>Update
ServiceResources GraphQL fragment</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/features/projects/resources/settings/gql/getResources.gql

- Added autoscaler fields to the ServiceResources fragment



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-68fa86be385f712ad875b055ed1403ec2086642aa31030bb2826615a136dd0ad">+12/-0</a>&nbsp;
&nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>getRunService.graphql</strong><dd><code>Update
getRunService GraphQL query</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/gql/services/getRunService.graphql

- Added autoscaler field to the getRunService query



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-2e51dd3f3ee7a2bafdb9c754e57ce461cdf2b2ef754edc7d99120fa859e6c1d2">+3/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>getRunServices.graphql</strong><dd><code>Update
RunServiceConfig GraphQL fragment</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/gql/services/getRunServices.graphql

- Added autoscaler field to the RunServiceConfig fragment



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-bddccfbe4a76e754eff4aff107d8780424c0f3606c781a8e6e3a5c9c099b7c57">+3/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

</table></details></td></tr><tr><td><strong>Formatting</strong></td><td><details><summary>1
files</summary><table>
<tr>
  <td>
    <details>
<summary><strong>index.tsx</strong><dd><code>Update styling in services
page</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/src/pages/[workspaceSlug]/[appSlug]/services/index.tsx

- Minor styling adjustment for consistency



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2872/files#diff-e418070dbf550b4e92ae18e7f29bdd757d923b69e128cebe70f2fd3a47fcabd9">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    
</table></details></td></tr></tr></tbody></table>

___

> 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull
request to receive relevant information

---------

Co-authored-by: Hassan Ben Jobrane <hsanbenjobrane@gmail.com>
2024-09-27 14:43:45 +01:00
David Barroso
c4aa159f1f feat (docs): added advanced TLS document (#2899)
### **PR Type**
Enhancement, Documentation


___

### **Description**
- Added a new comprehensive guide on advanced TLS configuration,
including TLS Client Authentication
- Updated the networking guide to include GRPC support and configuration
- Added a new 'platform/tls' page to the documentation structure
- Included a detailed step-by-step guide for setting up TLS Client
Authentication
- Provided examples of TLS configuration and usage with curl commands
- Updated the list of supported service types to include 'grpc'


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Documentation</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>breezy-fans-kiss.md</strong><dd><code>Add changeset for
TLS documentation</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

.changeset/breezy-fans-kiss.md

<li>Added a new changeset file for documenting the addition of an
advanced <br>TLS document<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2899/files#diff-bf8f8eb1d2664b9b181786b0f66cd6d70ba166236be705d4bfc24f13f7c5726a">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>mint.json</strong><dd><code>Add TLS page to
documentation structure</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></summary>
<hr>

docs/mint.json

- Added a new page 'platform/tls' to the Platform group



</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2899/files#diff-c91a604899dfef4b2494c317f4fd39a7f22b79986095f580399347293d534deb">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    

<tr>
  <td>
    <details>
<summary><strong>tls.mdx</strong><dd><code>Add comprehensive TLS
configuration guide</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></summary>
<hr>

docs/platform/tls.mdx

<li>Added a new document explaining advanced TLS configuration<br> <li>
Included sections on TLS Client Authentication with setup guide<br> <li>
Provided examples of configuration and usage<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2899/files#diff-f55226dfd5f3fa0b171c0761a8fe569b9faaf2320c761b29c33dde88dc42a2a5">+185/-0</a>&nbsp;
</td>

</tr>                    
</table></td></tr><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>networking.mdx</strong><dd><code>Update networking
guide with GRPC support</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></summary>
<hr>

docs/guides/run/networking.mdx

<li>Updated the supported service types to include 'grpc'<br> <li> Added
a new section on GRPC support with configuration example<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2899/files#diff-fb3f7f794126de1897c7c9db46fdef956df40aaf971fe76713b06735148e1219">+19/-1</a>&nbsp;
&nbsp; </td>

</tr>                    
</table></td></tr></tr></tbody></table>

___

> 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull
request to receive relevant information
2024-09-27 12:53:38 +02:00
David Barroso
91f0465cbc feat (docs): added turnstile guide (#2896)
### **PR Type**
Enhancement, Documentation


___

### **Description**
- Added a comprehensive guide on integrating Cloudflare's Turnstile for
bot protection in the Auth API
- Guide includes:
  - Overview of Turnstile and its benefits
  - Step-by-step integration process
  - Configuration examples for Nhost projects
  - Code snippets for frontend implementation
- Updated navigation in mint.json to include the new bot protection
guide
- Enhances security documentation for Nhost users


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Documentation</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>bot-protection.mdx</strong><dd><code>New Bot Protection
Guide Using Cloudflare Turnstile</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></summary>
<hr>

docs/guides/auth/bot-protection.mdx

<li>Added new guide for integrating Cloudflare's Turnstile for bot
<br>protection<br> <li> Includes overview, benefits, and step-by-step
integration instructions<br> <li> Provides code examples and
configuration details<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2896/files#diff-138cec6e6b432e18aaad258bb16e7e8b08c926b9850943600e6cba0fde8cec91">+76/-0</a>&nbsp;
&nbsp; </td>

</tr>                    
</table></td></tr><tr><td><strong>Configuration
changes</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>mint.json</strong><dd><code>Update Navigation to
Include Bot Protection Guide</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

docs/mint.json

<li>Added "guides/auth/bot-protection" to the authentication guides
<br>section<br>


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/2896/files#diff-c91a604899dfef4b2494c317f4fd39a7f22b79986095f580399347293d534deb">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>                    
</table></td></tr></tr></tbody></table>

___

> 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull
request to receive relevant information
2024-09-27 11:58:51 +02:00
Hassan Ben Jobrane
6f61262045 fix: unlink nhost-js dependency from sveltekit example (#2898) 2024-09-26 10:28:57 +01:00
30 changed files with 1237 additions and 202 deletions

View File

@@ -1,5 +1,11 @@
# @nhost/dashboard
## 1.30.0
### Minor Changes
- 50441a8: feat: add ui for project autoscaler settings and run services autoscaler settings
## 1.29.0
### Minor Changes

View File

@@ -39,22 +39,6 @@ test('should create and delete a run service', async () => {
await page.getByPlaceholder(/service name/i).click();
await page.getByPlaceholder(/service name/i).fill('test');
const sliderRail = page.locator(
'.space-y-4 > .MuiSlider-root > .MuiSlider-rail',
);
// Get the bounding box of the slider rail to determine where to click
const box = await sliderRail.boundingBox();
if (box) {
// Calculate the position to click (start of the rail)
const x = box.x + 1; // A little offset to ensure click inside the rail
const y = box.y + box.height / 2; // Middle of the rail height-wise
// Perform the click
await page.mouse.click(x, y);
}
await page.getByRole('button', { name: /create/i }).click();
await expect(

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "1.29.0",
"version": "1.30.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

View File

@@ -0,0 +1,45 @@
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 InfoOutlinedIcon(props: IconProps, ref: ForwardedRef<SVGSVGElement>) {
return (
<SvgIcon
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-label="Info"
stroke="currentColor"
ref={ref}
{...props}
>
<path
fill="none"
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
fill="none"
d="M12 16V12"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
fill="none"
d="M12 8H12.01"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
InfoOutlinedIcon.displayName = 'NhostInfoOutlinedIcon';
export default forwardRef(InfoOutlinedIcon);

View File

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

View File

@@ -14,6 +14,7 @@ import {
fireEvent,
render,
screen,
waitFor,
waitForElementToBeRemoved,
within,
} from '@/tests/testUtils';
@@ -78,7 +79,7 @@ test('should show the sliders if the switch is enabled', async () => {
await user.click(screen.getByRole('checkbox'));
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
expect(screen.getAllByRole('slider')).toHaveLength(12);
expect(screen.getAllByRole('slider')).toHaveLength(9);
});
test('should not show an empty state message if there is data available', async () => {
@@ -89,7 +90,7 @@ test('should not show an empty state message if there is data available', async
).toBeInTheDocument();
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
expect(screen.getAllByRole('slider')).toHaveLength(12);
expect(screen.getAllByRole('slider')).toHaveLength(9);
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 8/i);
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 16384 mib/i);
});
@@ -267,7 +268,7 @@ test('should display a red button when custom resources are disabled', async ()
await screen.findByRole('slider', { name: /total available vcpu/i }),
).toBeInTheDocument();
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getAllByRole('checkbox')[0]);
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
@@ -297,7 +298,8 @@ test('should hide the pricing information when custom resource allocation is dis
await screen.findByRole('slider', { name: /total available vcpu/i }),
).toBeInTheDocument();
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getAllByRole('checkbox')[0]);
await user.click(screen.getByRole('button', { name: /save/i }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
@@ -333,6 +335,8 @@ test('should show a warning message when resources are overallocated', async ()
});
test('should change pricing based on selected replicas', async () => {
const user = userEvent.setup();
render(<ResourcesForm />);
expect(
@@ -343,23 +347,31 @@ test('should change pricing based on selected replicas', async () => {
/approximate cost: \$425\.00\/mo/i,
);
changeSliderValue(
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
2,
const hasuraReplicasInput = screen.getAllByPlaceholderText('Replicas')[0];
await user.click(hasuraReplicasInput);
await user.clear(hasuraReplicasInput);
await user.type(hasuraReplicasInput, '2');
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
await waitFor(() =>
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
/approximate cost: \$525\.00\/mo/i,
),
);
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
/approximate cost: \$525\.00\/mo/i,
);
await user.click(hasuraReplicasInput);
await user.clear(hasuraReplicasInput);
await user.type(hasuraReplicasInput, '1');
changeSliderValue(
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
1,
);
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
/approximate cost: \$425\.00\/mo/i,
);
await waitFor(() => {
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
/approximate cost: \$425\.00\/mo/i,
);
});
});
test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 replica is selected', async () => {
@@ -378,10 +390,10 @@ test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 repl
20 * RESOURCE_VCPU_MULTIPLIER,
);
changeSliderValue(
screen.getByRole('slider', { name: /storage replicas/i }),
2,
);
const storageReplicasInput = screen.getAllByPlaceholderText('Replicas')[2];
await user.click(storageReplicasInput);
await user.clear(storageReplicasInput);
await user.type(storageReplicasInput, '2');
changeSliderValue(
screen.getByRole('slider', { name: /storage vcpu/i }),
@@ -402,12 +414,13 @@ test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 repl
),
).toBeInTheDocument();
const validationErrorMessage = screen.getByLabelText(
/vcpu and memory for this service must match the 1:2 ratio if more than one replica is selected\./i,
);
expect(validationErrorMessage).toBeInTheDocument();
expect(validationErrorMessage).toHaveStyle({ color: '#f13154' });
await waitFor(() => {
const validationErrorMessage = screen.getByText(
/vCPU and Memory for this service must follow a 1:2 ratio when more than one replica is selected or when the autoscaler is activated\./i,
);
expect(validationErrorMessage).toBeInTheDocument();
expect(validationErrorMessage).toHaveStyle({ color: '#f13154' });
});
});
test('should take replicas into account when confirming the resources', async () => {
@@ -436,11 +449,11 @@ test('should take replicas into account when confirming the resources', async ()
4 * RESOURCE_MEMORY_MULTIPLIER,
);
// setting up hasura
changeSliderValue(
screen.getByRole('slider', { name: /hasura graphql replicas/i }),
3,
);
const hasuraReplicasInput = screen.getAllByPlaceholderText('Replicas')[0];
await user.click(hasuraReplicasInput);
await user.clear(hasuraReplicasInput);
await user.type(hasuraReplicasInput, '3');
changeSliderValue(
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
2.5 * RESOURCE_VCPU_MULTIPLIER,
@@ -450,8 +463,12 @@ test('should take replicas into account when confirming the resources', async ()
5 * RESOURCE_MEMORY_MULTIPLIER,
);
const authReplicasInput = screen.getAllByPlaceholderText('Replicas')[1];
// setting up auth
changeSliderValue(screen.getByRole('slider', { name: /auth replicas/i }), 2);
await user.click(authReplicasInput);
await user.clear(authReplicasInput);
await user.type(authReplicasInput, '2');
changeSliderValue(
screen.getByRole('slider', { name: /auth vcpu/i }),
1.5 * RESOURCE_VCPU_MULTIPLIER,
@@ -461,11 +478,12 @@ test('should take replicas into account when confirming the resources', async ()
3 * RESOURCE_MEMORY_MULTIPLIER,
);
const storageReplicasInput = screen.getAllByPlaceholderText('Replicas')[2];
// setting up storage
changeSliderValue(
screen.getByRole('slider', { name: /storage replicas/i }),
4,
);
await user.click(storageReplicasInput);
await user.clear(storageReplicasInput);
await user.type(storageReplicasInput, '4');
changeSliderValue(
screen.getByRole('slider', { name: /storage vcpu/i }),
2.5 * RESOURCE_VCPU_MULTIPLIER,

View File

@@ -37,12 +37,14 @@ function getInitialServiceResources(
data: GetResourcesQuery,
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
) {
const { compute, replicas } = data?.config?.[service]?.resources || {};
const { compute, replicas, autoscaler } =
data?.config?.[service]?.resources || {};
return {
replicas,
vcpu: compute?.cpu || 0,
memory: compute?.memory || 0,
autoscale: autoscaler || null,
};
}
@@ -100,21 +102,29 @@ export default function ResourcesForm() {
replicas: initialDatabaseResources.replicas || 1,
vcpu: initialDatabaseResources.vcpu || 1000,
memory: initialDatabaseResources.memory || 2048,
autoscale: !!initialDatabaseResources.autoscale || false,
maxReplicas: initialDatabaseResources.autoscale?.maxReplicas || 10,
},
hasura: {
replicas: initialHasuraResources.replicas || 1,
vcpu: initialHasuraResources.vcpu || 500,
memory: initialHasuraResources.memory || 1536,
autoscale: !!initialHasuraResources.autoscale || false,
maxReplicas: initialHasuraResources.autoscale?.maxReplicas || 10,
},
auth: {
replicas: initialAuthResources.replicas || 1,
vcpu: initialAuthResources.vcpu || 250,
memory: initialAuthResources.memory || 256,
autoscale: !!initialAuthResources.autoscale || false,
maxReplicas: initialAuthResources.autoscale?.maxReplicas || 10,
},
storage: {
replicas: initialStorageResources.replicas || 1,
vcpu: initialStorageResources.vcpu || 250,
memory: initialStorageResources.memory || 256,
autoscale: !!initialStorageResources.autoscale || false,
maxReplicas: initialStorageResources.autoscale?.maxReplicas || 10,
},
},
resolver: yupResolver(resourceSettingsValidationSchema),
@@ -181,6 +191,11 @@ export default function ResourcesForm() {
memory: formValues.database.memory,
},
replicas: formValues.database.replicas,
autoscaler: formValues.database.autoscale
? {
maxReplicas: formValues.database.maxReplicas,
}
: null,
}
: null,
},
@@ -192,6 +207,11 @@ export default function ResourcesForm() {
memory: formValues.hasura.memory,
},
replicas: formValues.hasura.replicas,
autoscaler: formValues.hasura.autoscale
? {
maxReplicas: formValues.hasura.maxReplicas,
}
: null,
}
: null,
},
@@ -203,6 +223,11 @@ export default function ResourcesForm() {
memory: formValues.auth.memory,
},
replicas: formValues.auth.replicas,
autoscaler: formValues.auth.autoscale
? {
maxReplicas: formValues.auth.maxReplicas,
}
: null,
}
: null,
},
@@ -214,6 +239,11 @@ export default function ResourcesForm() {
memory: formValues.storage.memory,
},
replicas: formValues.storage.replicas,
autoscaler: formValues.storage.autoscale
? {
maxReplicas: formValues.storage.maxReplicas,
}
: null,
}
: null,
},
@@ -253,21 +283,29 @@ export default function ResourcesForm() {
totalAvailableMemory: 4096,
database: {
replicas: 1,
maxReplicas: 1,
autoscale: false,
vcpu: 1000,
memory: 2048,
},
hasura: {
replicas: 1,
maxReplicas: 1,
autoscale: false,
vcpu: 500,
memory: 1536,
},
auth: {
replicas: 1,
maxReplicas: 1,
autoscale: false,
vcpu: 250,
memory: 256,
},
storage: {
replicas: 1,
maxReplicas: 1,
autoscale: false,
vcpu: 250,
memory: 256,
},

View File

@@ -1,24 +1,30 @@
import { ControlledSwitch } from '@/components/form/ControlledSwitch';
import { Box } from '@/components/ui/v2/Box';
import { ExclamationIcon } from '@/components/ui/v2/icons/ExclamationIcon';
import { InfoOutlinedIcon } from '@/components/ui/v2/icons/InfoOutlinedIcon';
import { Input } from '@/components/ui/v2/Input';
import { Slider } from '@/components/ui/v2/Slider';
import { Text } from '@/components/ui/v2/Text';
import { Link } from '@/components/ui/v2/Link';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { prettifyMemory } from '@/features/projects/resources/settings/utils/prettifyMemory';
import { prettifyVCPU } from '@/features/projects/resources/settings/utils/prettifyVCPU';
import type { ResourceSettingsFormValues } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
import { Alert } from '@/components/ui/v2/Alert';
import {
MAX_SERVICE_MEMORY,
MAX_SERVICE_REPLICAS,
MAX_SERVICE_VCPU,
MIN_SERVICE_MEMORY,
MIN_SERVICE_REPLICAS,
MIN_SERVICE_VCPU,
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
import {
RESOURCE_MEMORY_LOCKED_STEP,
RESOURCE_MEMORY_STEP,
RESOURCE_VCPU_STEP,
} from '@/utils/constants/common';
import debounce from 'lodash.debounce';
import { useFormContext, useWatch } from 'react-hook-form';
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
export interface ServiceResourcesFormFragmentProps {
/**
@@ -52,10 +58,14 @@ export default function ServiceResourcesFormFragment({
setValue,
trigger: triggerValidation,
formState,
register,
} = useFormContext<ResourceSettingsFormValues>();
const formValues = useWatch<ResourceSettingsFormValues>();
const serviceValues = formValues[serviceKey];
const isRatioLocked = serviceValues.replicas > 1 || serviceValues.autoscale;
const resourceMemoryStep = isRatioLocked ? RESOURCE_MEMORY_LOCKED_STEP : RESOURCE_MEMORY_STEP;
// Total allocated CPU for all resources
const totalAllocatedVCPU = Object.keys(formValues)
.filter(
@@ -83,15 +93,27 @@ export default function ServiceResourcesFormFragment({
formValues.totalAvailableMemory - totalAllocatedMemory;
const allowedMemory = remainingMemory + serviceValues.memory;
function handleReplicaChange(value: string) {
// Debounce revalidation to prevent excessive re-renders
const handleReplicaChange = debounce((value: string) => {
const updatedReplicas = parseInt(value, 10);
if (updatedReplicas < MIN_SERVICE_REPLICAS) {
return;
}
setValue(`${serviceKey}.replicas`, updatedReplicas, { shouldDirty: true });
triggerValidation(`${serviceKey}.replicas`);
triggerValidation(`${serviceKey}.replicas`)
triggerValidation(`${serviceKey}.memory`);
}, 500);
const handleMaxReplicasChange = debounce((value: string) => {
const updatedMaxReplicas = parseInt(value, 10);
setValue(`${serviceKey}.maxReplicas`, updatedMaxReplicas, {
shouldDirty: true,
});
triggerValidation(`${serviceKey}.maxReplicas`);
triggerValidation(`${serviceKey}.memory`);
}, 500);
const handleSwitchChange = () => {
triggerValidation(`${serviceKey}.memory`);
}
function handleVCPUChange(value: string) {
@@ -103,9 +125,13 @@ export default function ServiceResourcesFormFragment({
setValue(`${serviceKey}.vcpu`, updatedVCPU, { shouldDirty: true });
if (isRatioLocked) {
setValue(`${serviceKey}.memory`, updatedVCPU * 2.048, { shouldDirty: true });
}
// trigger validation for "replicas" field
if (!disableReplicas) {
triggerValidation(`${serviceKey}.replicas`);
triggerValidation(`${serviceKey}.memory`);
}
}
@@ -118,9 +144,13 @@ export default function ServiceResourcesFormFragment({
setValue(`${serviceKey}.memory`, updatedMemory, { shouldDirty: true });
if (isRatioLocked) {
setValue(`${serviceKey}.vcpu`, updatedMemory / 2.048, { shouldDirty: true });
}
// trigger validation for "replicas" field
if (!disableReplicas) {
triggerValidation(`${serviceKey}.replicas`);
triggerValidation(`${serviceKey}.memory`);
}
}
@@ -135,7 +165,7 @@ export default function ServiceResourcesFormFragment({
</Box>
<Box className="grid grid-flow-row gap-2">
<Box className="grid grid-flow-col items-center justify-between gap-2">
<Box className="grid items-center justify-between grid-flow-col gap-2">
<Text>
Allocated vCPUs:{' '}
<span className="font-medium">
@@ -165,7 +195,7 @@ export default function ServiceResourcesFormFragment({
</Box>
<Box className="grid grid-flow-row gap-2">
<Box className="grid grid-flow-col items-center justify-between gap-2">
<Box className="grid items-center justify-between grid-flow-col gap-2">
<Text>
Allocated Memory:{' '}
<span className="font-medium">
@@ -187,53 +217,112 @@ export default function ServiceResourcesFormFragment({
value={serviceValues.memory}
onChange={(_event, value) => handleMemoryChange(value.toString())}
max={MAX_SERVICE_MEMORY}
step={RESOURCE_MEMORY_STEP}
step={resourceMemoryStep}
allowed={allowedMemory}
aria-label={`${title} Memory`}
marks
/>
{formState.errors[serviceKey]?.memory?.message ? (
<Alert severity="error">
{formState.errors[serviceKey]?.memory?.message}
</Alert>
) : null}
</Box>
{!disableReplicas && (
<Box className="grid grid-flow-row gap-2">
<Box className="grid grid-flow-col items-center justify-start gap-2">
<Text
color={
formState.errors?.[serviceKey]?.replicas?.message
? 'error'
: 'primary'
}
aria-errormessage={`${serviceKey}-replicas-error-tooltip`}
>
Replicas:{' '}
<span className="font-medium">{serviceValues.replicas}</span>
</Text>
{formState.errors?.[serviceKey]?.replicas?.message ? (
<Tooltip
title={formState.errors[serviceKey].replicas.message}
id={`${serviceKey}-replicas-error-tooltip`}
>
<ExclamationIcon
color="error"
className="h-4 w-4"
aria-hidden="false"
/>
</Tooltip>
) : null}
<Box className="flex flex-col justify-between gap-4 lg:flex-row">
<Box className="flex flex-col gap-4 lg:flex-row lg:gap-8">
<Box className="flex flex-row items-center gap-2">
{formState.errors?.[serviceKey]?.replicas?.message ? (
<Tooltip
title={formState.errors[serviceKey]?.replicas?.message}
id={`${serviceKey}-replicas-error-tooltip`}
>
<ExclamationIcon
color="error"
className="w-4 h-4"
aria-hidden="false"
/>
</Tooltip>
) : null}
<Text className="w-28 lg:w-auto">Replicas</Text>
<Input
{...register(`${serviceKey}.replicas`)}
onChange={(event) => handleReplicaChange(event.target.value)}
type="number"
id={`${serviceKey}.replicas`}
data-testid={`${serviceKey}.replicas`}
placeholder="Replicas"
className="max-w-28"
hideEmptyHelperText
error={!!formState.errors?.[serviceKey]?.replicas}
fullWidth
autoComplete="off"
/>
</Box>
<Box className="flex flex-row items-center gap-2">
{formState.errors?.[serviceKey]?.maxReplicas?.message ? (
<Tooltip
title={formState.errors[serviceKey]?.maxReplicas?.message}
id={`${serviceKey}-maxReplicas-error-tooltip`}
>
<ExclamationIcon
color="error"
className="w-4 h-4"
aria-hidden="false"
/>
</Tooltip>
) : null}
<Text className="w-28 text-nowrap lg:w-auto">Max Replicas</Text>
<Input
{...register(`${serviceKey}.maxReplicas`)}
onChange={(event) =>
handleMaxReplicasChange(event.target.value)
}
type="number"
id={`${serviceKey}.maxReplicas`}
placeholder="10"
disabled={!formValues[serviceKey].autoscale}
className="max-w-28"
hideEmptyHelperText
error={!!formState.errors?.[serviceKey]?.maxReplicas}
fullWidth
autoComplete="off"
/>
</Box>
</Box>
<Box className="flex flex-row items-center gap-3">
<ControlledSwitch
{...register(`${serviceKey}.autoscale`)}
onChange={handleSwitchChange}
/>
<Text>Autoscaler</Text>
<Tooltip
title={`Enable autoscaler to automatically provision extra ${title} replicas when needed.`}
>
<InfoOutlinedIcon className="w-4 h-4 text-black" />
</Tooltip>
</Box>
<Slider
value={serviceValues.replicas}
onChange={(_event, value) => handleReplicaChange(value.toString())}
min={0}
max={MAX_SERVICE_REPLICAS}
step={1}
aria-label={`${title} Replicas`}
marks
/>
</Box>
)}
{
!disableReplicas && (
<Text>
Learn more about{' '}
<Link
href="https://docs.nhost.io/platform/service-replicas"
target="_blank"
rel="noopener noreferrer"
underline="hover"
className="font-medium"
>
Service Replicas
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
</Link>
</Text>
)
}
</Box>
);
}

View File

@@ -2,6 +2,7 @@ import { Alert } from '@/components/ui/v2/Alert';
import { Box } from '@/components/ui/v2/Box';
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
import { Slider, sliderClasses } from '@/components/ui/v2/Slider';
import { calculateBillableResources } from '@/features/projects/resources/settings/utils/calculateBillableResources';
import { Text } from '@/components/ui/v2/Text';
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
import { useProPlan } from '@/features/projects/common/hooks/useProPlan';
@@ -46,6 +47,7 @@ export default function TotalResourcesFormFragment({
error: proPlanError,
loading: proPlanLoading,
} = useProPlan();
const { setValue } = useFormContext<ResourceSettingsFormValues>();
const formValues = useWatch<ResourceSettingsFormValues>();
@@ -65,9 +67,38 @@ export default function TotalResourcesFormFragment({
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
RESOURCE_VCPU_PRICE;
const updatedPrice = isPlatform
? priceForTotalAvailableVCPU + proPlan.price
: 0;
const billableResources = calculateBillableResources(
{
replicas: formValues.database?.replicas,
vcpu: formValues.database?.vcpu,
},
{
replicas: formValues.hasura?.replicas,
vcpu: formValues.hasura?.vcpu,
},
{
replicas: formValues.auth?.replicas,
vcpu: formValues.auth?.vcpu,
},
{
replicas: formValues.storage?.replicas,
vcpu: formValues.storage?.vcpu,
},
);
const computeUpdatedPrice = () => {
if (!isPlatform) {
return 0;
}
return (
Math.max(
priceForTotalAvailableVCPU,
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) *
RESOURCE_VCPU_PRICE,
) + proPlan.price
);
};
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
getAllocatedResources(formValues);
@@ -114,14 +145,14 @@ export default function TotalResourcesFormFragment({
Total available compute for your project:
</Text>
{initialPrice !== updatedPrice && (
{initialPrice !== computeUpdatedPrice() && (
<Text className="flex flex-row items-center justify-end gap-2">
<Text component="span" color="secondary">
${initialPrice.toFixed(2)}/mo
</Text>
<ArrowRightIcon />
<Text component="span" className="font-medium">
${updatedPrice.toFixed(2)}/mo
${computeUpdatedPrice().toFixed(2)}/mo
</Text>
</Text>
)}

View File

@@ -6,6 +6,9 @@ fragment ServiceResources on ConfigConfig {
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
hasura {
@@ -15,6 +18,9 @@ fragment ServiceResources on ConfigConfig {
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
postgres {
@@ -24,6 +30,9 @@ fragment ServiceResources on ConfigConfig {
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
storage {
@@ -33,6 +42,9 @@ fragment ServiceResources on ConfigConfig {
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
}

View File

@@ -81,23 +81,13 @@ const serviceValidationSchema = Yup.object({
.label('Replicas')
.required()
.min(1)
.max(MAX_SERVICE_REPLICAS)
.test(
'is-matching-ratio',
`vCPU and Memory for this service must match the 1:${RESOURCE_VCPU_MEMORY_RATIO} ratio if more than one replica is selected.`,
(replicas: number, { parent }) => {
if (replicas === 1) {
return true;
}
return (
parent.memory /
RESOURCE_MEMORY_MULTIPLIER /
(parent.vcpu / RESOURCE_VCPU_MULTIPLIER) ===
RESOURCE_VCPU_MEMORY_RATIO
);
},
),
.max(MAX_SERVICE_REPLICAS),
maxReplicas: Yup.number()
.label('Max Replicas')
.required()
.min(MIN_SERVICE_REPLICAS)
.max(MAX_SERVICE_REPLICAS),
autoscale: Yup.boolean().label('Autoscale').required(),
vcpu: Yup.number()
.label('vCPUs')
.required()
@@ -106,7 +96,23 @@ const serviceValidationSchema = Yup.object({
memory: Yup.number()
.required()
.min(MIN_SERVICE_MEMORY)
.max(MAX_SERVICE_MEMORY),
.max(MAX_SERVICE_MEMORY)
.test(
'is-matching-ratio',
`vCPU and Memory for this service must follow a 1:${RESOURCE_VCPU_MEMORY_RATIO} ratio when more than one replica is selected or when the autoscaler is activated.`,
(memory: number, { parent }) => {
if (parent.replicas === 1 && !parent.autoscale) {
return true;
}
return (
memory /
RESOURCE_MEMORY_MULTIPLIER /
(parent.vcpu / RESOURCE_VCPU_MULTIPLIER) ===
RESOURCE_VCPU_MEMORY_RATIO
);
},
),
});
export const resourceSettingsValidationSchema = Yup.object({

View File

@@ -78,6 +78,7 @@ export default function ServiceForm({
memory: 128,
},
replicas: 1,
autoscaler: null,
},
reValidateMode: 'onSubmit',
resolver: yupResolver(validationSchema),
@@ -123,6 +124,11 @@ export default function ServiceForm({
capacity: item.capacity,
})),
replicas: sanitizedValues.replicas,
autoscaler: sanitizedValues.autoscaler
? {
maxReplicas: sanitizedValues.autoscaler?.maxReplicas,
}
: null,
},
environment: sanitizedValues.environment.map((item) => ({
name: item.name,
@@ -316,7 +322,7 @@ export default function ServiceForm({
<Tooltip title="Name of the service, must be unique per project.">
<InfoIcon
aria-label="Info"
className="h-4 w-4"
className="w-4 h-4"
color="primary"
/>
</Tooltip>
@@ -356,7 +362,7 @@ export default function ServiceForm({
>
<InfoIcon
aria-label="Info"
className="h-4 w-4"
className="w-4 h-4"
color="primary"
/>
</Tooltip>
@@ -387,7 +393,7 @@ export default function ServiceForm({
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
<InfoIcon
aria-label="Info"
className="h-4 w-4"
className="w-4 h-4"
color="primary"
/>
</Tooltip>
@@ -435,7 +441,7 @@ export default function ServiceForm({
{createServiceFormError && (
<Alert
severity="error"
className="grid grid-flow-col items-center justify-between px-4 py-3"
className="grid items-center justify-between grid-flow-col px-4 py-3"
>
<span className="text-left">
<strong>Error:</strong> {createServiceFormError.message}

View File

@@ -25,6 +25,12 @@ export const validationSchema = Yup.object({
memory: Yup.number().min(MIN_SERVICES_MEM).max(MAX_SERVICES_MEM).required(),
}),
replicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS).required(),
autoscaler: Yup.object()
.shape({
maxReplicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS),
})
.nullable()
.default(undefined),
ports: Yup.array().of(
Yup.object().shape({
port: Yup.number().required(),

View File

@@ -38,7 +38,8 @@ export default function PortsFormSection() {
const showURL = (index: number) =>
formValues.subdomain &&
formValues.ports[index]?.type === PortTypes.HTTP &&
(formValues.ports[index]?.type === PortTypes.HTTP ||
formValues.ports[index]?.type === PortTypes.GRPC) &&
formValues.ports[index]?.publish;
return (
@@ -106,7 +107,7 @@ export default function PortsFormSection() {
},
}}
>
{['http', 'tcp', 'udp']?.map((portType) => (
{['http', 'tcp', 'udp', 'grpc']?.map((portType) => (
<Option key={portType} value={portType}>
{portType}
</Option>

View File

@@ -2,4 +2,5 @@ export enum PortTypes {
HTTP = 'http',
TCP = 'tcp',
UDP = 'udp',
GRPC = 'grpc',
}

View File

@@ -1,16 +1,32 @@
import { Box } from '@/components/ui/v2/Box';
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
import { Slider } from '@/components/ui/v2/Slider';
import { InfoOutlinedIcon } from '@/components/ui/v2/icons/InfoOutlinedIcon';
import { Input } from '@/components/ui/v2/Input';
import { Switch } from '@/components/ui/v2/Switch';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { MAX_SERVICE_REPLICAS } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
import type { ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
import { useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
export default function ReplicasFormSection() {
const { setValue } = useFormContext<ServiceFormValues>();
const {
register,
setValue,
trigger: triggerValidation,
} = useFormContext<ServiceFormValues>();
const { replicas, autoscaler } = useWatch<ServiceFormValues>();
const [autoscalerEnabled, setAutoscalerEnabled] = useState(!!autoscaler);
const { replicas } = useWatch<ServiceFormValues>();
const toggleAutoscalerEnabled = async (enabled: boolean) => {
setAutoscalerEnabled(enabled);
if (!enabled) {
setValue('autoscaler', null);
} else {
setValue('autoscaler.maxReplicas', 10);
}
};
const handleReplicasChange = (value: string) => {
const updatedReplicas = parseInt(value, 10);
@@ -20,42 +36,85 @@ export default function ReplicasFormSection() {
// TODO Trigger revalidate storage
};
const handleMaxReplicasChange = (value: string) => {
const updatedReplicas = parseInt(value, 10);
setValue('autoscaler.maxReplicas', updatedReplicas, { shouldDirty: true });
triggerValidation('autoscaler.maxReplicas');
};
return (
<Box className="space-y-4 rounded border-1 p-4">
<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">
Replicas ({replicas})
</Text>
<Tooltip
title={
<span>
Number of replicas for the service. Multiple replicas can process
requests/work in parallel. You can set replicas to 0 to pause the
service. Refer to{' '}
<Text className="text-white">
Learn more about{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://docs.nhost.io/guides/run/resources"
href="https://docs.nhost.io/platform/service-replicas"
className="underline"
>
resources
</a>{' '}
for more information.
</span>
Service Replicas
</a>
</Text>
}
>
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
<InfoIcon aria-label="Info" className="w-4 h-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 className="flex flex-col justify-between gap-4 lg:flex-row">
<Box className="flex flex-col gap-4 lg:flex-row lg:gap-8">
<Box className="flex flex-row items-center gap-2">
<Text className="w-28 lg:w-auto">Replicas</Text>
<Input
{...register('replicas')}
onChange={(event) => handleReplicasChange(event.target.value)}
type="number"
id="replicas"
placeholder="Replicas"
className="max-w-28"
hideEmptyHelperText
fullWidth
onWheel={(e) => (e.target as HTMLInputElement).blur()}
autoComplete="off"
/>
</Box>
<Box className="flex flex-row items-center gap-2">
<Text className="w-28 text-nowrap lg:w-auto">Max Replicas</Text>
<Input
value={autoscaler?.maxReplicas}
onChange={(event) => handleMaxReplicasChange(event.target.value)}
type="number"
id="maxReplicas"
placeholder="10"
disabled={!autoscalerEnabled}
className="max-w-28"
hideEmptyHelperText
fullWidth
onWheel={(e) => (e.target as HTMLInputElement).blur()}
autoComplete="off"
/>
</Box>
</Box>
<Box className="flex flex-row items-center gap-3">
<Switch
checked={autoscalerEnabled}
onChange={(e) => toggleAutoscalerEnabled(e.target.checked)}
className="self-center"
/>
<Text>Autoscaler</Text>
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
<InfoOutlinedIcon className="w-4 h-4 text-black" />
</Tooltip>
</Box>
</Box>
</Box>
);
}

View File

@@ -73,6 +73,7 @@ export default function ServicesList({
cpu: 62,
memory: 128,
},
autoscaler: service?.config?.resources?.autoscaler,
replicas: service.config?.resources?.replicas,
storage: service.config?.resources?.storage,
}}
@@ -197,4 +198,4 @@ export default function ServicesList({
))}
</Box>
);
}
}

View File

@@ -19,6 +19,9 @@ query getRunService($id: uuid!, $resolve: Boolean!) {
capacity
}
replicas
autoscaler {
maxReplicas
}
}
environment {
name

View File

@@ -15,6 +15,9 @@ fragment RunServiceConfig on ConfigRunServiceConfig {
capacity
}
replicas
autoscaler {
maxReplicas
}
}
environment {
name

View File

@@ -48,7 +48,7 @@ export default function ServicesPage() {
openDrawer({
title: (
<Box className="flex flex-row items-center space-x-2">
<CubeIcon className="h-5 w-5" />
<CubeIcon className="w-5 h-5" />
<Text>Create a new run service</Text>
</Box>
),
@@ -60,6 +60,7 @@ export default function ServicesPage() {
cpu: 62,
memory: 128,
},
autoscaler: parsedConfig?.resources?.autoscaler,
image: parsedConfig?.image?.image,
command: parsedConfig?.command?.join(' '),
ports: parsedConfig?.ports.map((item) => ({
@@ -104,7 +105,7 @@ export default function ServicesPage() {
openDrawer({
title: (
<Box className="flex flex-row items-center space-x-2">
<CubeIcon className="h-5 w-5" />
<CubeIcon className="w-5 h-5" />
<Text>Create a new service</Text>
</Box>
),
@@ -125,23 +126,23 @@ export default function ServicesPage() {
if (services.length === 0 && !loading) {
return (
<Container className="mx-auto max-w-9xl space-y-5 overflow-x-hidden">
<Container className="mx-auto space-y-5 overflow-x-hidden max-w-9xl">
<div className="flex flex-row place-content-end">
<Button
variant="contained"
color="primary"
onClick={openCreateServiceDialog}
startIcon={<PlusIcon className="h-4 w-4" />}
startIcon={<PlusIcon className="w-4 h-4" />}
disabled={!isPlatform}
>
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" />
<Box className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border rounded-lg shadow-sm">
<ServicesIcon className="w-10 h-10" />
<div className="flex flex-col space-y-1">
<Text className="text-center font-medium" variant="h3">
<Text className="font-medium text-center" variant="h3">
No custom services are available
</Text>
<Text variant="subtitle1" className="text-center">
@@ -149,13 +150,13 @@ export default function ServicesPage() {
</Text>
</div>
{isPlatform ? (
<div className="flex flex-row place-content-between rounded-lg ">
<div className="flex flex-row rounded-lg place-content-between ">
<Button
variant="contained"
color="primary"
className="w-full"
onClick={openCreateServiceDialog}
startIcon={<PlusIcon className="h-4 w-4" />}
startIcon={<PlusIcon className="w-4 h-4" />}
>
Add service
</Button>
@@ -168,12 +169,12 @@ export default function ServicesPage() {
return (
<div className="flex flex-col">
<Box className="flex flex-row place-content-end border-b-1 p-4">
<Box className="flex flex-row p-4 place-content-end border-b-1">
<Button
variant="contained"
color="primary"
onClick={openCreateServiceDialog}
startIcon={<PlusIcon className="h-4 w-4" />}
startIcon={<PlusIcon className="w-4 h-4" />}
disabled={!isPlatform}
>
Add service

View File

@@ -1412,6 +1412,29 @@ export type ConfigGlobalUpdateInput = {
export type ConfigGrafana = {
__typename?: 'ConfigGrafana';
adminPassword: Scalars['String'];
alerting?: Maybe<ConfigGrafanaAlerting>;
contacts?: Maybe<ConfigGrafanaContacts>;
smtp?: Maybe<ConfigGrafanaSmtp>;
};
export type ConfigGrafanaAlerting = {
__typename?: 'ConfigGrafanaAlerting';
enabled?: Maybe<Scalars['Boolean']>;
};
export type ConfigGrafanaAlertingComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanaAlertingComparisonExp>>;
_not?: InputMaybe<ConfigGrafanaAlertingComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanaAlertingComparisonExp>>;
enabled?: InputMaybe<ConfigBooleanComparisonExp>;
};
export type ConfigGrafanaAlertingInsertInput = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
export type ConfigGrafanaAlertingUpdateInput = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
export type ConfigGrafanaComparisonExp = {
@@ -1419,14 +1442,255 @@ export type ConfigGrafanaComparisonExp = {
_not?: InputMaybe<ConfigGrafanaComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanaComparisonExp>>;
adminPassword?: InputMaybe<ConfigStringComparisonExp>;
alerting?: InputMaybe<ConfigGrafanaAlertingComparisonExp>;
contacts?: InputMaybe<ConfigGrafanaContactsComparisonExp>;
smtp?: InputMaybe<ConfigGrafanaSmtpComparisonExp>;
};
export type ConfigGrafanaContacts = {
__typename?: 'ConfigGrafanaContacts';
discord?: Maybe<Array<ConfigGrafanacontactsDiscord>>;
emails?: Maybe<Array<Scalars['String']>>;
pagerduty?: Maybe<Array<ConfigGrafanacontactsPagerduty>>;
slack?: Maybe<Array<ConfigGrafanacontactsSlack>>;
webhook?: Maybe<Array<ConfigGrafanacontactsWebhook>>;
};
export type ConfigGrafanaContactsComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanaContactsComparisonExp>>;
_not?: InputMaybe<ConfigGrafanaContactsComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanaContactsComparisonExp>>;
discord?: InputMaybe<ConfigGrafanacontactsDiscordComparisonExp>;
emails?: InputMaybe<ConfigStringComparisonExp>;
pagerduty?: InputMaybe<ConfigGrafanacontactsPagerdutyComparisonExp>;
slack?: InputMaybe<ConfigGrafanacontactsSlackComparisonExp>;
webhook?: InputMaybe<ConfigGrafanacontactsWebhookComparisonExp>;
};
export type ConfigGrafanaContactsInsertInput = {
discord?: InputMaybe<Array<ConfigGrafanacontactsDiscordInsertInput>>;
emails?: InputMaybe<Array<Scalars['String']>>;
pagerduty?: InputMaybe<Array<ConfigGrafanacontactsPagerdutyInsertInput>>;
slack?: InputMaybe<Array<ConfigGrafanacontactsSlackInsertInput>>;
webhook?: InputMaybe<Array<ConfigGrafanacontactsWebhookInsertInput>>;
};
export type ConfigGrafanaContactsUpdateInput = {
discord?: InputMaybe<Array<ConfigGrafanacontactsDiscordUpdateInput>>;
emails?: InputMaybe<Array<Scalars['String']>>;
pagerduty?: InputMaybe<Array<ConfigGrafanacontactsPagerdutyUpdateInput>>;
slack?: InputMaybe<Array<ConfigGrafanacontactsSlackUpdateInput>>;
webhook?: InputMaybe<Array<ConfigGrafanacontactsWebhookUpdateInput>>;
};
export type ConfigGrafanaInsertInput = {
adminPassword: Scalars['String'];
alerting?: InputMaybe<ConfigGrafanaAlertingInsertInput>;
contacts?: InputMaybe<ConfigGrafanaContactsInsertInput>;
smtp?: InputMaybe<ConfigGrafanaSmtpInsertInput>;
};
export type ConfigGrafanaSmtp = {
__typename?: 'ConfigGrafanaSmtp';
host: Scalars['String'];
password: Scalars['String'];
port: Scalars['ConfigPort'];
sender: Scalars['String'];
user: Scalars['String'];
};
export type ConfigGrafanaSmtpComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanaSmtpComparisonExp>>;
_not?: InputMaybe<ConfigGrafanaSmtpComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanaSmtpComparisonExp>>;
host?: InputMaybe<ConfigStringComparisonExp>;
password?: InputMaybe<ConfigStringComparisonExp>;
port?: InputMaybe<ConfigPortComparisonExp>;
sender?: InputMaybe<ConfigStringComparisonExp>;
user?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigGrafanaSmtpInsertInput = {
host: Scalars['String'];
password: Scalars['String'];
port: Scalars['ConfigPort'];
sender: Scalars['String'];
user: Scalars['String'];
};
export type ConfigGrafanaSmtpUpdateInput = {
host?: InputMaybe<Scalars['String']>;
password?: InputMaybe<Scalars['String']>;
port?: InputMaybe<Scalars['ConfigPort']>;
sender?: InputMaybe<Scalars['String']>;
user?: InputMaybe<Scalars['String']>;
};
export type ConfigGrafanaUpdateInput = {
adminPassword?: InputMaybe<Scalars['String']>;
alerting?: InputMaybe<ConfigGrafanaAlertingUpdateInput>;
contacts?: InputMaybe<ConfigGrafanaContactsUpdateInput>;
smtp?: InputMaybe<ConfigGrafanaSmtpUpdateInput>;
};
export type ConfigGrafanacontactsDiscord = {
__typename?: 'ConfigGrafanacontactsDiscord';
avatarUrl: Scalars['String'];
url: Scalars['String'];
};
export type ConfigGrafanacontactsDiscordComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanacontactsDiscordComparisonExp>>;
_not?: InputMaybe<ConfigGrafanacontactsDiscordComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanacontactsDiscordComparisonExp>>;
avatarUrl?: InputMaybe<ConfigStringComparisonExp>;
url?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigGrafanacontactsDiscordInsertInput = {
avatarUrl: Scalars['String'];
url: Scalars['String'];
};
export type ConfigGrafanacontactsDiscordUpdateInput = {
avatarUrl?: InputMaybe<Scalars['String']>;
url?: InputMaybe<Scalars['String']>;
};
export type ConfigGrafanacontactsPagerduty = {
__typename?: 'ConfigGrafanacontactsPagerduty';
class: Scalars['String'];
component: Scalars['String'];
group: Scalars['String'];
integrationKey: Scalars['String'];
severity: Scalars['String'];
};
export type ConfigGrafanacontactsPagerdutyComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanacontactsPagerdutyComparisonExp>>;
_not?: InputMaybe<ConfigGrafanacontactsPagerdutyComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanacontactsPagerdutyComparisonExp>>;
class?: InputMaybe<ConfigStringComparisonExp>;
component?: InputMaybe<ConfigStringComparisonExp>;
group?: InputMaybe<ConfigStringComparisonExp>;
integrationKey?: InputMaybe<ConfigStringComparisonExp>;
severity?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigGrafanacontactsPagerdutyInsertInput = {
class: Scalars['String'];
component: Scalars['String'];
group: Scalars['String'];
integrationKey: Scalars['String'];
severity: Scalars['String'];
};
export type ConfigGrafanacontactsPagerdutyUpdateInput = {
class?: InputMaybe<Scalars['String']>;
component?: InputMaybe<Scalars['String']>;
group?: InputMaybe<Scalars['String']>;
integrationKey?: InputMaybe<Scalars['String']>;
severity?: InputMaybe<Scalars['String']>;
};
export type ConfigGrafanacontactsSlack = {
__typename?: 'ConfigGrafanacontactsSlack';
endpointURL: Scalars['String'];
iconEmoji: Scalars['String'];
iconURL: Scalars['String'];
mentionChannel: Scalars['String'];
mentionGroups: Array<Scalars['String']>;
mentionUsers: Array<Scalars['String']>;
recipient: Scalars['String'];
token: Scalars['String'];
url: Scalars['String'];
username: Scalars['String'];
};
export type ConfigGrafanacontactsSlackComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanacontactsSlackComparisonExp>>;
_not?: InputMaybe<ConfigGrafanacontactsSlackComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanacontactsSlackComparisonExp>>;
endpointURL?: InputMaybe<ConfigStringComparisonExp>;
iconEmoji?: InputMaybe<ConfigStringComparisonExp>;
iconURL?: InputMaybe<ConfigStringComparisonExp>;
mentionChannel?: InputMaybe<ConfigStringComparisonExp>;
mentionGroups?: InputMaybe<ConfigStringComparisonExp>;
mentionUsers?: InputMaybe<ConfigStringComparisonExp>;
recipient?: InputMaybe<ConfigStringComparisonExp>;
token?: InputMaybe<ConfigStringComparisonExp>;
url?: InputMaybe<ConfigStringComparisonExp>;
username?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigGrafanacontactsSlackInsertInput = {
endpointURL: Scalars['String'];
iconEmoji: Scalars['String'];
iconURL: Scalars['String'];
mentionChannel: Scalars['String'];
mentionGroups: Array<Scalars['String']>;
mentionUsers: Array<Scalars['String']>;
recipient: Scalars['String'];
token: Scalars['String'];
url: Scalars['String'];
username: Scalars['String'];
};
export type ConfigGrafanacontactsSlackUpdateInput = {
endpointURL?: InputMaybe<Scalars['String']>;
iconEmoji?: InputMaybe<Scalars['String']>;
iconURL?: InputMaybe<Scalars['String']>;
mentionChannel?: InputMaybe<Scalars['String']>;
mentionGroups?: InputMaybe<Array<Scalars['String']>>;
mentionUsers?: InputMaybe<Array<Scalars['String']>>;
recipient?: InputMaybe<Scalars['String']>;
token?: InputMaybe<Scalars['String']>;
url?: InputMaybe<Scalars['String']>;
username?: InputMaybe<Scalars['String']>;
};
export type ConfigGrafanacontactsWebhook = {
__typename?: 'ConfigGrafanacontactsWebhook';
authorizationCredentials: Scalars['String'];
authorizationScheme: Scalars['String'];
httpMethod: Scalars['String'];
maxAlerts: Scalars['Int'];
password: Scalars['String'];
url: Scalars['String'];
username: Scalars['String'];
};
export type ConfigGrafanacontactsWebhookComparisonExp = {
_and?: InputMaybe<Array<ConfigGrafanacontactsWebhookComparisonExp>>;
_not?: InputMaybe<ConfigGrafanacontactsWebhookComparisonExp>;
_or?: InputMaybe<Array<ConfigGrafanacontactsWebhookComparisonExp>>;
authorizationCredentials?: InputMaybe<ConfigStringComparisonExp>;
authorizationScheme?: InputMaybe<ConfigStringComparisonExp>;
httpMethod?: InputMaybe<ConfigStringComparisonExp>;
maxAlerts?: InputMaybe<ConfigIntComparisonExp>;
password?: InputMaybe<ConfigStringComparisonExp>;
url?: InputMaybe<ConfigStringComparisonExp>;
username?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigGrafanacontactsWebhookInsertInput = {
authorizationCredentials: Scalars['String'];
authorizationScheme: Scalars['String'];
httpMethod: Scalars['String'];
maxAlerts: Scalars['Int'];
password: Scalars['String'];
url: Scalars['String'];
username: Scalars['String'];
};
export type ConfigGrafanacontactsWebhookUpdateInput = {
authorizationCredentials?: InputMaybe<Scalars['String']>;
authorizationScheme?: InputMaybe<Scalars['String']>;
httpMethod?: InputMaybe<Scalars['String']>;
maxAlerts?: InputMaybe<Scalars['Int']>;
password?: InputMaybe<Scalars['String']>;
url?: InputMaybe<Scalars['String']>;
username?: InputMaybe<Scalars['String']>;
};
export type ConfigGraphql = {
@@ -1625,6 +1889,8 @@ export type ConfigHasuraSettings = {
enableRemoteSchemaPermissions?: Maybe<Scalars['Boolean']>;
/** HASURA_GRAPHQL_ENABLED_APIS */
enabledAPIs?: Maybe<Array<Scalars['ConfigHasuraAPIs']>>;
/** HASURA_GRAPHQL_INFER_FUNCTION_PERMISSIONS */
inferFunctionPermissions?: Maybe<Scalars['Boolean']>;
/** HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL */
liveQueriesMultiplexedRefetchInterval?: Maybe<Scalars['ConfigUint32']>;
/** HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES */
@@ -1641,6 +1907,7 @@ export type ConfigHasuraSettingsComparisonExp = {
enableConsole?: InputMaybe<ConfigBooleanComparisonExp>;
enableRemoteSchemaPermissions?: InputMaybe<ConfigBooleanComparisonExp>;
enabledAPIs?: InputMaybe<ConfigHasuraApIsComparisonExp>;
inferFunctionPermissions?: InputMaybe<ConfigBooleanComparisonExp>;
liveQueriesMultiplexedRefetchInterval?: InputMaybe<ConfigUint32ComparisonExp>;
stringifyNumericTypes?: InputMaybe<ConfigBooleanComparisonExp>;
};
@@ -1652,6 +1919,7 @@ export type ConfigHasuraSettingsInsertInput = {
enableConsole?: InputMaybe<Scalars['Boolean']>;
enableRemoteSchemaPermissions?: InputMaybe<Scalars['Boolean']>;
enabledAPIs?: InputMaybe<Array<Scalars['ConfigHasuraAPIs']>>;
inferFunctionPermissions?: InputMaybe<Scalars['Boolean']>;
liveQueriesMultiplexedRefetchInterval?: InputMaybe<Scalars['ConfigUint32']>;
stringifyNumericTypes?: InputMaybe<Scalars['Boolean']>;
};
@@ -1663,6 +1931,7 @@ export type ConfigHasuraSettingsUpdateInput = {
enableConsole?: InputMaybe<Scalars['Boolean']>;
enableRemoteSchemaPermissions?: InputMaybe<Scalars['Boolean']>;
enabledAPIs?: InputMaybe<Array<Scalars['ConfigHasuraAPIs']>>;
inferFunctionPermissions?: InputMaybe<Scalars['Boolean']>;
liveQueriesMultiplexedRefetchInterval?: InputMaybe<Scalars['ConfigUint32']>;
stringifyNumericTypes?: InputMaybe<Scalars['Boolean']>;
};
@@ -22956,14 +23225,14 @@ export type GetBackupPresignedUrlQueryVariables = Exact<{
export type GetBackupPresignedUrlQuery = { __typename?: 'query_root', getBackupPresignedUrl: { __typename?: 'BackupPresignedURL', url: string, expiresAt: any } };
export type ServiceResourcesFragment = { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null } | null };
export type ServiceResourcesFragment = { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null };
export type GetResourcesQueryVariables = Exact<{
appId: Scalars['uuid'];
}>;
export type GetResourcesQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } | null } | null } | null };
export type GetResourcesQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null } | null };
export type GetServerlessFunctionsSettingsQueryVariables = Exact<{
appId: Scalars['uuid'];
@@ -23516,9 +23785,9 @@ export type GetRunServiceQueryVariables = Exact<{
}>;
export type GetRunServiceQuery = { __typename?: 'query_root', runService?: { __typename?: 'run_service', id: any, subdomain: string, config?: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null }> | null } | null } | null };
export type GetRunServiceQuery = { __typename?: 'query_root', runService?: { __typename?: 'run_service', id: any, subdomain: string, config?: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null }> | null } | null } | null };
export type RunServiceConfigFragment = { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null };
export type RunServiceConfigFragment = { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null };
export type GetRunServicesQueryVariables = Exact<{
appID: Scalars['uuid'];
@@ -23528,7 +23797,7 @@ export type GetRunServicesQueryVariables = Exact<{
}>;
export type GetRunServicesQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', runServices: Array<{ __typename?: 'run_service', id: any, createdAt: any, updatedAt: any, subdomain: string, config?: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null } | null }>, runServices_aggregate: { __typename?: 'run_service_aggregate', aggregate?: { __typename?: 'run_service_aggregate_fields', count: number } | null } } | null };
export type GetRunServicesQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', runServices: Array<{ __typename?: 'run_service', id: any, createdAt: any, updatedAt: any, subdomain: string, config?: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null } | null }>, runServices_aggregate: { __typename?: 'run_service_aggregate', aggregate?: { __typename?: 'run_service_aggregate_fields', count: number } | null } } | null };
export type GetLocalRunServiceConfigsQueryVariables = Exact<{
appID: Scalars['uuid'];
@@ -23536,7 +23805,7 @@ export type GetLocalRunServiceConfigsQueryVariables = Exact<{
}>;
export type GetLocalRunServiceConfigsQuery = { __typename?: 'query_root', runServiceConfigs: Array<{ __typename?: 'ConfigRunServiceConfigWithID', serviceID: any, config: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null } }> };
export type GetLocalRunServiceConfigsQuery = { __typename?: 'query_root', runServiceConfigs: Array<{ __typename?: 'ConfigRunServiceConfigWithID', serviceID: any, config: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null } }> };
export type RunServiceRateLimitFragment = { __typename?: 'ConfigRunServiceConfig', name: any, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null }> | null };
@@ -23691,6 +23960,9 @@ export const ServiceResourcesFragmentDoc = gql`
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
hasura {
@@ -23700,6 +23972,9 @@ export const ServiceResourcesFragmentDoc = gql`
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
postgres {
@@ -23709,6 +23984,9 @@ export const ServiceResourcesFragmentDoc = gql`
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
storage {
@@ -23718,6 +23996,9 @@ export const ServiceResourcesFragmentDoc = gql`
memory
}
replicas
autoscaler {
maxReplicas
}
}
}
}
@@ -24005,6 +24286,9 @@ export const RunServiceConfigFragmentDoc = gql`
capacity
}
replicas
autoscaler {
maxReplicas
}
}
environment {
name
@@ -27752,6 +28036,9 @@ export const GetRunServiceDocument = gql`
capacity
}
replicas
autoscaler {
maxReplicas
}
}
environment {
name

View File

@@ -50,6 +50,11 @@ export const RESOURCE_VCPU_STEP = 0.25 * RESOURCE_VCPU_MULTIPLIER;
*/
export const RESOURCE_MEMORY_STEP = 128;
/**
* Number of steps between GiB of RAM when the ratio is locked.
*/
export const RESOURCE_MEMORY_LOCKED_STEP = 4 * RESOURCE_MEMORY_STEP;
/**
* Price per vCPU.
*

View File

@@ -1,5 +1,15 @@
# @nhost/docs
## 2.18.0
### Minor Changes
- c4aa159: feat: added advanced TLS document
### Patch Changes
- 91f0465: feat: added turnstile guide
## 2.17.2
### Patch Changes

View File

@@ -0,0 +1,76 @@
---
title: Bot Protection
description: Use turnstile to protect from bots
icon: robot
---
## Overview
To safeguard your Auth API against automated attacks from scripts and bots, you can implement [Cloudflare's Turnstile](https://www.cloudflare.com/en-gb/products/turnstile/). Turnstile offers CAPTCHA-like protection without user friction, as it doesn't require solving puzzles.
![Turnstile Check in Action](/images/guides/auth/turnstile/turnstile.gif)
## Integration Benefits
1. **Selective Protection**: Auth integrates Turnstile specifically for all signup methods.
2. **API Accessibility**: Other API endpoints remain accessible for legitimate programmatic use.
3. **Bot Deterrence**: Manual verification during signup discourages unwanted bot activity.
This approach balances security with usability, ensuring robust protection where it matters most.
## Guide
<Steps>
<Step title="Create a widget on Cloudflare">
Sign up on [Cloudflare](https://dash.cloudflare.com) if you haven't already.
Go to your account -> Turnstile -> Add Widget. Then:
- Set a descriptive name
- In the domain, enter your frontend's domain
- Set widget mode as "managed"
Then click on "create" and write down the site key and the secret key.
</Step>
<Step title="Enable Turnstile integration on auth">
Start by adding the following configuration to your Nhost project:
<Tabs>
<Tab title="Config">
```toml
[auth.signUp.turnstile]
secretKey = 'turnstileSecretKey'
```
</Tab>
</Tabs>
Replace `turnstileSecretKey` with the secret key from the first step.
</Step>
<Step title="Integrate turnstile into your sign up form">
To integrate turnstile into your sign up form you can refer to [Cloudfare's documentation](https://developers.cloudflare.com/turnstile/tutorials/login-pages). Just keep in mind a few things:
- You don't need to do any verification of the response, just pass it to the Auth service on the `/signup/...` request in the header `x-cf-turnstile-response`.
- The "server side verification" is done by the auth service and will return a forbidden status error if the header is not present or if the check didn't pass.
- You will need to use the site key from step 1 to configure turnstile in your form
</Step>
<Step title="Pass turnstile's response to the signup request">
To pass the response as a header change your request to include the header. For instance:
```js
await signUpEmailPassword(
email,
password,
{
displayName,
},
{
headers: {
'x-cf-turnstile-response': turnstileResponse,
},
},
);
```
In the following [PR](https://github.com/nhost/nhost/pull/2897/files) you can see the changes that were needed in our very own dashboard to integrate turnstile.
</Step>
</Steps>

View File

@@ -95,7 +95,7 @@ publish = true
</Tab>
</Tabs>
<Info>Currently, only services of type `http` can be exposed to the internet.</Info>
<Info>Currently, only services of type `http` and `grpc` can be exposed to the internet.</Info>
2. Once the service of type `http` is published, you can connect to it using a URL with the following format:
@@ -105,3 +105,21 @@ publish = true
`https://zlbmqjfczuwqvsquujno-3000.svc.eu-central-1.nhost.run`
## GRPC
GRPC services are supported, however, they are only supported via [custom domains](/platform/custom-domains). To expose a GRPC service to the internet you can use the following configuration:
``` toml
...
[[ports]]
type = "grpc"
port = 5000
publish = false
[[ports.ingresses]]
fqdn = ["grpc.domain.com"]
...
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -81,7 +81,8 @@
"platform/secrets",
"platform/deployments",
"platform/custom-domains",
"platform/rate-limits"
"platform/rate-limits",
"platform/tls"
]
},
{
@@ -155,6 +156,7 @@
"guides/auth/sign-in-phone-number",
"guides/auth/sign-in-webauthn",
"guides/auth/elevated-permissions",
"guides/auth/bot-protection",
"guides/auth/email-templates",
"guides/auth/custom-jwts"
]

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/docs",
"version": "2.17.2",
"version": "2.18.0",
"private": true,
"scripts": {
"start": "mintlify dev"

185
docs/platform/tls.mdx Normal file
View File

@@ -0,0 +1,185 @@
---
title: TLS Configuration
description: Advanced TLS settings
icon: file-certificate
---
Below you can find some advanced TLS functionality you can enable.
<Warning>
Advanced TLS settings are only available with [custom domains](/platform/custom-domains)
</Warning>
## TLS Client Authentication
With TLS Client Authentication you can configure our platform to require clients to include a client certificate signed by a CA of your choosing. If the client doesn't provide a certificate or the certificate isn't signed by the correct CA the request will be denied. If the request includes a valid certificate the request will be forwarded to your service and will include information about the TLS configuration.
To configure TLS client authentication you can use the configuration below:
```toml
[[functions.resources.ingress]]
fqdn = ["func.acme.com"]
[functions.resources.networking.ingresses.tls]
clientCA = "{{ secrets.client_ca }}"
```
### Headers
The following headers will be added to all successful requests:
- `ssl-client-cert`: The client cetificate that was used
- `ssl-client-issuer-dn`: Client certificate's issuer DN
- `ssl-client-subject-dn`: Client certificate;s distinguished name
- `ssl-client-verify`: Result of the operation. As we only forward requests on success the value should always be `SUCCESS`.
### Guide
Here is a quick guide on how to enable TLS client authentication for the functions service using self-signed certificates.
#### Creating the CA
First we need to create a CA that will be used to sign and validate the client certificates.
<Steps>
<Step title="Generate the CA private key">
First, we need to create a private key for our Certificate Authority. We'll use a 4096-bit RSA key for strong security.
```
openssl genrsa -aes256 -out ca.key 4096
```
This command will prompt you to enter a passphrase to protect the CA private key. Make sure to choose a strong passphrase and keep it safe.
</Step>
<Step title="Create the CA certificate">
Now that we have the private key, let's create a self-signed CA certificate:
```
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt
```
This command will prompt you for various details to include in the certificate. The most important field is the Common Name (CN), which should be a descriptive name for your CA.
</Step>
</Steps>
The resulting `ca.key` file will be needed later to sign client certificates while the result `ca.crt` will be needed to validate them.
#### Creating client certificates
Now we can proceed to create client certificats. You can repeat the steps below to create as many as you need.
<Steps>
<Step title="Generate a private key for the client">
First, we'll create a private key for the client certificate:
```
openssl genrsa -out client.key 2048
```
</Step>
<Step title="Create a Certificate Signing Request (CSR) for the client">
Now, we'll create a CSR for the client certificate:
```
openssl req -new -key client.key -out client.csr
```
Fill in the prompted information. The Common Name (CN) should typically be the name of the client or user.
</Step>
<Step title="Create a configuration file for the client certificate">
Create a file named `client.ext` with the following content:
```
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
extendedKeyUsage = clientAuth
```
This configuration specifies that the certificate is for client authentication.
</Step>
<Step title="Generate the client certificate">
Now, we'll use our CA to sign the client certificate:
```
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365 -sha256 -extfile client.ext
```
This command will prompt you for the CA private key passphrase you set earlier.
</Step>
The resulting `client.key` and `client.crt` files will be needed by the user to authenticate requests.
</Steps>
#### Configure your service
With everything in place you can configure your service. Imagine we have an already deployed project and we want to enable this for all functions. First we will head to the dashboard -> project -> settings -> secrets and create a new secret named `CLIENT_CA` with the contents of the `ca.crt` file. Afterwards we will deploy the following configuration:
```toml
[[functions.resources.networking.ingresses]]
fqdn = [ "functions.acme.com" ]
[functions.resources.networking.ingresses.tls]
clientCA = "{{ secrets.CLIENT_CA }}"
```
#### Profit
Our project has a function that echoes back request parameters, including headers. We will use this to inspect the TLS headers added to the request and that you can use for further validation if you need it. First, we can query the function without passing any client certificate:
```
$ curl https://functions.acme.com/v1/echo
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>
<hr><center>nginx</center>
</body>
</html>
```
As you can see the request was rejected. Now we can add a valid client certificate and the corresponding key to the request:
```
$ curl --cert client.crt --key client.key https://functions.acme.com/v1/echo | jq
{
"headers": {
"accept": "*/*",
"ssl-client-cert": "-----BEGIN%20CERTIFICATE-----...ommitted for brevity...-----END%20CERTIFICATE-----%0A",
"ssl-client-issuer-dn": "OU=IT,O=Acme Org.,L=Stockholm,ST=Stockholm,C=SE",
"ssl-client-subject-dn": "emailAddress=jane@acme.org,OU=IT,O=Acme Org.,L=Sausalito,ST=California,C=US",
"ssl-client-verify": "SUCCESS",
"user-agent": "curl/8.7.1",
"x-forwarded-for": "2001:8b1:8ac9:4100:46b1:3412:1342:9319",
"x-forwarded-host": "functions.acme.com",
"x-forwarded-port": "443",
"x-forwarded-proto": "https",
"x-forwarded-scheme": "https",
"x-real-ip": "2001:8b1:8ac9:4100:46b1:3412:1342:9319",
"x-request-id": "8f80c26ee873bfc9db7ce9073eecd17a",
"x-scheme": "https",
"content-length": 0
},
"query": {},
"node": "v20.17.0",
"arch": "arm64"
}
```
With a valid certificate you can see the request went through and it includes the `ssl-client-*` headers providing additional information about it.

187
pnpm-lock.yaml generated
View File

@@ -1046,7 +1046,7 @@ importers:
devDependencies:
'@nhost/nhost-js':
specifier: ^3.1.5
version: link:../../../packages/nhost-js
version: 3.1.10(graphql@16.8.1)
'@playwright/test':
specifier: ^1.41.0
version: 1.41.0
@@ -1085,16 +1085,16 @@ importers:
version: 4.2.19
svelte-check:
specifier: ^3.6.8
version: 3.8.6(@babel/core@7.25.2)(postcss@8.4.47)(svelte@4.2.19)
version: 3.8.6(postcss@8.4.47)(svelte@4.2.19)
tailwindcss:
specifier: ^3.4.3
version: 3.4.10(ts-node@10.9.2)
version: 3.4.10
typescript:
specifier: ^5.4.3
version: 5.5.4
vite:
specifier: ^5.4.6
version: 5.4.6(@types/node@16.18.106)(sass@1.32.0)
version: 5.4.6(@types/node@16.18.106)
vitest:
specifier: ^0.25.8
version: 0.25.8
@@ -6967,7 +6967,7 @@ packages:
/@graphql-typed-document-node/core@3.2.0(graphql@16.8.1):
resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
peerDependencies:
graphql: 16.8.1
graphql: '>=16.8.1'
dependencies:
graphql: 16.8.1
@@ -8852,6 +8852,57 @@ packages:
jwt-decode: 4.0.0
dev: false
/@nhost/graphql-js@0.3.0(graphql@16.8.1):
resolution: {integrity: sha512-CVYq6wx0VbaYdpUBmfNO/6mZatHB5+YBCqFjWyxhpN1nzHCHEO6rgdL7j0qk31OFE6XAX0z7AQZSXg1Pn63GUw==}
peerDependencies:
graphql: '>=16.8.1'
dependencies:
'@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1)
base-64: 1.0.0
graphql: 16.8.1
isomorphic-unfetch: 3.1.0
jwt-decode: 4.0.0
transitivePeerDependencies:
- encoding
dev: true
/@nhost/hasura-auth-js@2.6.0:
resolution: {integrity: sha512-gy2H/JlwSzpfFdpczFaVwheGq95SxmyzxJVTimBLCdVTl2yrnCZ3Zvk8gDpCcGLga/u+HjgRK+ymjH+RdNgiTg==}
dependencies:
'@simplewebauthn/browser': 9.0.1
fetch-ponyfill: 7.1.0
js-cookie: 3.0.5
jwt-decode: 4.0.0
xstate: 4.38.3
transitivePeerDependencies:
- encoding
dev: true
/@nhost/hasura-storage-js@2.5.1:
resolution: {integrity: sha512-I3rOSa095lcR9BUmNw7dOoXLPWL39WOcrb0paUBFX4h3ltR92ILEHTZ38hN6bZSv157ZdqkIFNL/M2G45SSf7g==}
dependencies:
fetch-ponyfill: 7.1.0
form-data: 4.0.0
graphql: 16.8.1
xstate: 4.38.3
transitivePeerDependencies:
- encoding
dev: true
/@nhost/nhost-js@3.1.10(graphql@16.8.1):
resolution: {integrity: sha512-9KOX1krHu1UYAxTCUuRgRlaD97Nylzstck9YRSYwW27dHqDKhWUM5OWwOmOxJ2/W+Ty0V6EYbxuW2LRzrsdt1A==}
peerDependencies:
graphql: '>=16.8.1'
dependencies:
'@nhost/graphql-js': 0.3.0(graphql@16.8.1)
'@nhost/hasura-auth-js': 2.6.0
'@nhost/hasura-storage-js': 2.5.1
graphql: 16.8.1
isomorphic-unfetch: 3.1.0
transitivePeerDependencies:
- encoding
dev: true
/@nhost/react-apollo@12.0.6(@apollo/client@3.11.4)(@nhost/react@packages+react)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-6Q4uN7PvC6UqS4YPKbjv/q/9FMP4SECdEcZFrfaKfJrcWyoAA5MRwJeQwDnD3uhx+npEUNgTbBxezXHjYH3AYw==}
peerDependencies:
@@ -10800,11 +10851,9 @@ packages:
resolution: {integrity: sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==}
dependencies:
'@simplewebauthn/types': 9.0.1
dev: false
/@simplewebauthn/types@9.0.1:
resolution: {integrity: sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==}
dev: false
/@simplewebauthn/typescript-types@6.2.1:
resolution: {integrity: sha512-qScvkt0nP0Uy/xeeunlXAkJni9wtecsvxwLELSgiWRx/KRVZy1SGDHsKAfQowpIeDmLDyhWxUoN7qUgvgWCiAQ==}
@@ -12087,7 +12136,7 @@ packages:
svelte: 4.2.19
tiny-glob: 0.2.9
undici: 5.28.4
vite: 5.4.6(@types/node@16.18.106)(sass@1.32.0)
vite: 5.4.6(@types/node@16.18.106)
transitivePeerDependencies:
- supports-color
dev: true
@@ -12103,7 +12152,7 @@ packages:
'@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.19)(vite@5.4.6)
debug: 4.3.7
svelte: 4.2.19
vite: 5.4.6(@types/node@16.18.106)(sass@1.32.0)
vite: 5.4.6(@types/node@16.18.106)
transitivePeerDependencies:
- supports-color
dev: true
@@ -12122,7 +12171,7 @@ packages:
magic-string: 0.30.11
svelte: 4.2.19
svelte-hmr: 0.15.3(svelte@4.2.19)
vite: 5.4.6(@types/node@16.18.106)(sass@1.32.0)
vite: 5.4.6(@types/node@16.18.106)
vitefu: 0.2.5(vite@5.4.6)
transitivePeerDependencies:
- supports-color
@@ -15677,7 +15726,6 @@ packages:
/base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
dev: false
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -19757,7 +19805,6 @@ packages:
node-fetch: 2.6.13
transitivePeerDependencies:
- encoding
dev: false
/figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
@@ -20721,7 +20768,7 @@ packages:
resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==}
engines: {node: '>=10'}
peerDependencies:
graphql: 16.8.1
graphql: '>=16.8.1'
dependencies:
graphql: 16.8.1
tslib: 2.7.0
@@ -22205,11 +22252,10 @@ packages:
/isomorphic-unfetch@3.1.0:
resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==}
dependencies:
node-fetch: 2.7.0(encoding@0.1.13)
node-fetch: 2.7.0
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
dev: false
/isomorphic-ws@5.0.0(ws@8.17.1):
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
@@ -23681,7 +23727,6 @@ packages:
/jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
dev: false
/katex@0.16.11:
resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==}
@@ -26063,7 +26108,17 @@ packages:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
/node-fetch@2.7.0(encoding@0.1.13):
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
@@ -27303,6 +27358,23 @@ packages:
yaml: 1.10.2
dev: true
/postcss-load-config@4.0.2(postcss@8.4.47):
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
engines: {node: '>= 14'}
peerDependencies:
postcss: '>=8.4.31'
ts-node: '>=9.0.0'
peerDependenciesMeta:
postcss:
optional: true
ts-node:
optional: true
dependencies:
lilconfig: 3.1.2
postcss: 8.4.47
yaml: 2.5.0
dev: true
/postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.2):
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
engines: {node: '>= 14'}
@@ -31173,7 +31245,7 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
/svelte-check@3.8.6(@babel/core@7.25.2)(postcss@8.4.47)(svelte@4.2.19):
/svelte-check@3.8.6(postcss@8.4.47)(svelte@4.2.19):
resolution: {integrity: sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==}
hasBin: true
peerDependencies:
@@ -31184,7 +31256,7 @@ packages:
picocolors: 1.1.0
sade: 1.8.1
svelte: 4.2.19
svelte-preprocess: 5.1.4(@babel/core@7.25.2)(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4)
svelte-preprocess: 5.1.4(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4)
typescript: 5.5.4
transitivePeerDependencies:
- '@babel/core'
@@ -31224,7 +31296,7 @@ packages:
svelte: 4.2.19
dev: true
/svelte-preprocess@5.1.4(@babel/core@7.25.2)(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4):
/svelte-preprocess@5.1.4(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4):
resolution: {integrity: sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==}
engines: {node: '>= 16.0.0'}
requiresBuild: true
@@ -31262,7 +31334,6 @@ packages:
typescript:
optional: true
dependencies:
'@babel/core': 7.25.2
'@types/pug': 2.0.10
detect-indent: 6.1.0
magic-string: 0.30.11
@@ -31410,6 +31481,37 @@ packages:
- ts-node
dev: false
/tailwindcss@3.4.10:
resolution: {integrity: sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==}
engines: {node: '>=14.0.0'}
hasBin: true
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
chokidar: 3.6.0
didyoumean: 1.2.2
dlv: 1.1.3
fast-glob: 3.3.2
glob-parent: 6.0.2
is-glob: 4.0.3
jiti: 1.21.6
lilconfig: 2.1.0
micromatch: 4.0.8
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.0
postcss: 8.4.47
postcss-import: 15.1.0(postcss@8.4.47)
postcss-js: 4.0.1(postcss@8.4.47)
postcss-load-config: 4.0.2(postcss@8.4.47)
postcss-nested: 6.2.0(postcss@8.4.47)
postcss-selector-parser: 6.1.2
resolve: 1.22.8
sucrase: 3.35.0
transitivePeerDependencies:
- ts-node
dev: true
/tailwindcss@3.4.10(ts-node@10.9.2):
resolution: {integrity: sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==}
engines: {node: '>=14.0.0'}
@@ -33205,6 +33307,45 @@ packages:
- typescript
dev: true
/vite@5.4.6(@types/node@16.18.106):
resolution: {integrity: sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
'@types/node': 16.18.106
esbuild: 0.21.5
postcss: 8.4.47
rollup: 4.22.4
optionalDependencies:
fsevents: 2.3.3
dev: true
/vite@5.4.6(@types/node@16.18.106)(sass@1.32.0):
resolution: {integrity: sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -33330,7 +33471,7 @@ packages:
vite:
optional: true
dependencies:
vite: 5.4.6(@types/node@16.18.106)(sass@1.32.0)
vite: 5.4.6(@types/node@16.18.106)
dev: true
/vitest@0.25.8:
@@ -33368,7 +33509,7 @@ packages:
tinybench: 2.9.0
tinypool: 0.3.1
tinyspy: 1.1.1
vite: 5.4.6(@types/node@16.18.106)(sass@1.32.0)
vite: 5.4.6(@types/node@16.18.106)
transitivePeerDependencies:
- less
- lightningcss