Compare commits
45 Commits
release-20
...
@nhost/nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
257815d519 | ||
|
|
55d8bb5a89 | ||
|
|
18f942f464 | ||
|
|
2a2e54c4d8 | ||
|
|
6a735523b4 | ||
|
|
4d6b7228d9 | ||
|
|
3dcbacf188 | ||
|
|
5c2269ef92 | ||
|
|
52a38feca7 | ||
|
|
f218058c89 | ||
|
|
dda0c67fa4 | ||
|
|
db2f44d7c0 | ||
|
|
9735fa238b | ||
|
|
58dec6e7b2 | ||
|
|
526183ab88 | ||
|
|
435b65a65a | ||
|
|
35a2f1203c | ||
|
|
be3b85bbc8 | ||
|
|
cffdec585c | ||
|
|
8b12426157 | ||
|
|
4cf6677284 | ||
|
|
fdaaf19057 | ||
|
|
a7cd02c965 | ||
|
|
3d70c63d1b | ||
|
|
ba55c1b779 | ||
|
|
852f13b273 | ||
|
|
a44a1d48d6 | ||
|
|
b63250d1cb | ||
|
|
caa8bd75ec | ||
|
|
40c0d7b914 | ||
|
|
3773ad7cca | ||
|
|
6f122521e9 | ||
|
|
a18b545d2a | ||
|
|
0263cc9e92 | ||
|
|
d1ceedef05 | ||
|
|
bdd84dd3ca | ||
|
|
45642322f4 | ||
|
|
d092a7c395 | ||
|
|
e5d3d1a39f | ||
|
|
f88bf2d034 | ||
|
|
49f2e55cb9 | ||
|
|
598b988fc1 | ||
|
|
2f0910367d | ||
|
|
e31eefae63 | ||
|
|
abb24afad5 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
feat: show contact us info and locked reason when project is locked
|
||||
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -22,6 +22,7 @@ env:
|
||||
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
||||
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
|
||||
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
|
||||
NHOST_PRO_TEST_PROJECT_NAME: ${{ vars.NHOST_PRO_TEST_PROJECT_NAME }}
|
||||
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
|
||||
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
|
||||
|
||||
28
.github/workflows/gen_ai_review.yaml
vendored
Normal file
28
.github/workflows/gen_ai_review.yaml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "gen: AI review"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
issue_comment:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
if: ${{ github.event.sender.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@v0.24
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
config.max_model_tokens: 100000
|
||||
config.model: "anthropic/claude-3-5-sonnet-20240620"
|
||||
config.model_turbo: "anthropic/claude-3-5-sonnet-20240620"
|
||||
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml']"
|
||||
@@ -2,5 +2,5 @@
|
||||
// $schema provides code completion hints to IDEs.
|
||||
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
|
||||
"moderate": true,
|
||||
"allowlist": ["trim-newlines"]
|
||||
"allowlist": ["vue-template-compiler", "micromatch", "path-to-regexp"]
|
||||
}
|
||||
|
||||
@@ -24,3 +24,4 @@ NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
@@ -1 +1,3 @@
|
||||
link-workspace-packages = false
|
||||
link-workspace-packages = false
|
||||
auto-install-peers = false
|
||||
resolution-mode=highest
|
||||
@@ -1,5 +1,94 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.29.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 55d8bb5: feat: integrate turnstile for signup verification
|
||||
- 2a2e54c: fix: update docs url in run services form tooltip
|
||||
- 18f942f: fix: display long error messages in error toast without overflow
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@13.0.0
|
||||
- @nhost/nextjs@2.1.22
|
||||
|
||||
## 1.28.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 52a38fe: chore: update dependencies to address security vulnerabilities
|
||||
- Updated dependencies [52a38fe]
|
||||
- @nhost/nextjs@2.1.21
|
||||
|
||||
## 1.28.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9735fa2: chore: remove broken link
|
||||
|
||||
## 1.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 526183a: feat: allow filtering users in "make request as" in graphql section
|
||||
- be3b85b: feat: add conceal errors toggle on auth settings page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 35a2f12: fix: prevent run service details from opening when attempting to delete
|
||||
- @nhost/react-apollo@12.0.6
|
||||
- @nhost/nextjs@2.1.20
|
||||
|
||||
## 1.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a7cd02c: fix: resolve rate limit query
|
||||
|
||||
## 1.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3773ad7: chore: update pricing information
|
||||
- b63250d: fix: not allow run service creation form resubmission while creating a run service
|
||||
- a44a1d4: feat: add rate limits settings page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@12.0.5
|
||||
- @nhost/nextjs@2.1.19
|
||||
|
||||
## 1.25.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d1ceede: feat: add setting to migrate postgres major and/or minor versions
|
||||
- e5d3d1a: fix: allow manually typing column for custom check in database row permissions
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@12.0.4
|
||||
- @nhost/nextjs@2.1.18
|
||||
|
||||
## 1.24.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 49f2e55: fix: use service subdomain in service form and service details dialog
|
||||
- 598b988: fix: use current project subdomain in ServiceDetailsDialog component
|
||||
|
||||
## 1.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- abb24af: chore: add redirect to support page when project is locked
|
||||
- 18a6455: feat: show contact us info and locked reason when project is locked
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e31eefa: fix: include ingresses field when updating run services
|
||||
|
||||
## 1.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -17,7 +17,7 @@ test.afterAll(async () => {
|
||||
});
|
||||
|
||||
test('should be able to create then delete a personal access token', async () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByRole('banner').getByRole('button').last().click();
|
||||
await page.getByRole('link', { name: /account settings/i }).click();
|
||||
await page
|
||||
|
||||
60
dashboard/e2e/ai/assistants.test.ts
Normal file
60
dashboard/e2e/ai/assistants.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /ai/i })
|
||||
.click();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete an Assistant', async () => {
|
||||
await page.getByRole('link', { name: 'Assistants' }).click();
|
||||
|
||||
await expect(page.getByText(/no assistants are configured/i)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Create a new assistant' }).click();
|
||||
await page.getByLabel('Name').fill('test');
|
||||
await page.getByLabel('Description').fill('test');
|
||||
await page.getByLabel('Instructions').fill('test');
|
||||
await page.getByLabel('Model').fill('gpt-3.5-turbo-1106');
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /test/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/more options/i).click();
|
||||
await page.getByRole('menuitem', { name: /delete test/i }).click();
|
||||
|
||||
await page.getByLabel('Confirm Delete Assistant').check();
|
||||
await page.getByRole('button', { name: 'Delete Assistant' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /no assistants are configured/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
55
dashboard/e2e/ai/auto-embeddings.test.ts
Normal file
55
dashboard/e2e/ai/auto-embeddings.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /ai/i })
|
||||
.click();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete an Auto-Embeddings', async () => {
|
||||
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('test');
|
||||
await page.getByLabel('Schema').fill('auth');
|
||||
await page.getByLabel('Table').fill('users');
|
||||
await page.getByLabel('Column').fill('email');
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await expect(page.getByRole('heading', { name: /test/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/more options/i).click();
|
||||
await page.getByRole('menuitem', { name: /delete test/i }).click();
|
||||
|
||||
await page.getByLabel('Confirm Delete Auto-').check();
|
||||
await page.getByRole('button', { name: 'Delete Auto-Embeddings' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /No Auto-Embeddings are configured/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
@@ -12,9 +12,9 @@ test('should be able to ban and unban a user', async ({ page }) => {
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
@@ -22,7 +22,9 @@ test('should be able to ban and unban a user', async ({ page }) => {
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/users`,
|
||||
);
|
||||
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
@@ -19,9 +19,9 @@ test.beforeEach(async () => {
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
@@ -29,7 +29,9 @@ test.beforeEach(async () => {
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/users`,
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
@@ -19,9 +19,9 @@ test.beforeEach(async () => {
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
@@ -29,7 +29,9 @@ test.beforeEach(async () => {
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/users`,
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
@@ -19,9 +19,9 @@ test.beforeEach(async () => {
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
@@ -29,7 +29,9 @@ test.beforeEach(async () => {
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/users`,
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject, prepareTable } from '@/e2e/utils';
|
||||
@@ -20,9 +20,9 @@ test.beforeEach(async () => {
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
@@ -55,7 +55,7 @@ test('should create a simple table', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -84,7 +84,7 @@ test('should create a table with unique constraints', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -113,7 +113,7 @@ test('should create a table with nullable columns', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -146,7 +146,7 @@ test('should create a table with an identity column', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -174,7 +174,7 @@ test('should create table with foreign key constraint', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
@@ -219,7 +219,7 @@ test('should create table with foreign key constraint', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -247,7 +247,7 @@ test('should not be able to create a table with a name that already exists', asy
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { deleteTable, openProject, prepareTable } from '@/e2e/utils';
|
||||
@@ -20,9 +20,9 @@ test.beforeEach(async () => {
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
@@ -53,7 +53,7 @@ test('should delete a table', async () => {
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await deleteTable({
|
||||
@@ -63,7 +63,7 @@ test('should delete a table', async () => {
|
||||
|
||||
// navigate to next URL
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/**`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/**`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -91,7 +91,7 @@ test('should not be able to delete a table if other tables have foreign keys ref
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
@@ -138,7 +138,7 @@ test('should not be able to delete a table if other tables have foreign keys ref
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
|
||||
`/${TEST_WORKSPACE_SLUG}/${PRO_TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -23,6 +23,11 @@ export const TEST_WORKSPACE_SLUG = slugify(TEST_WORKSPACE_NAME, {
|
||||
*/
|
||||
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME;
|
||||
|
||||
/**
|
||||
* Name of the pro test project to test against.
|
||||
*/
|
||||
export const PRO_TEST_PROJECT_NAME = process.env.NHOST_PRO_TEST_PROJECT_NAME;
|
||||
|
||||
/**
|
||||
* Slugified name of the project to test against.
|
||||
*/
|
||||
@@ -31,6 +36,14 @@ export const TEST_PROJECT_SLUG = slugify(TEST_PROJECT_NAME, {
|
||||
strict: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Slugified name of the pro project to test against.
|
||||
*/
|
||||
export const PRO_TEST_PROJECT_SLUG = slugify(PRO_TEST_PROJECT_NAME, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Hasura admin secret of the test project to use.
|
||||
*/
|
||||
|
||||
89
dashboard/e2e/run/run.test.ts
Normal file
89
dashboard/e2e/run/run.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
PRO_TEST_PROJECT_NAME,
|
||||
PRO_TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: PRO_TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: PRO_TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /run/i })
|
||||
.click();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete a run service', async () => {
|
||||
await page.getByRole('button', { name: 'Add service' }).first().click();
|
||||
await expect(page.getByText(/create a new service/i)).toBeVisible();
|
||||
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(
|
||||
page.getByRole('heading', { name: /confirm resources/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /confirm/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /service details/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /ok/i }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /test/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/more options/i).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: /delete service/i }).click();
|
||||
|
||||
await page.getByLabel(/confirm delete project #/i).check();
|
||||
|
||||
await page.getByRole('button', { name: /delete service/i }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('main')
|
||||
.locator('div')
|
||||
.filter({ hasText: 'No custom services are' })
|
||||
.nth(2),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -43,8 +43,8 @@ async function globalTeardown() {
|
||||
await adminSecretInput.press('Enter');
|
||||
|
||||
// note: getByRole doesn't work here
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).click();
|
||||
await hasuraPage.getByRole('link', { name: /sql/i }).click();
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).nth(0).click();
|
||||
await hasuraPage.locator('[data-test="sql-link"]').click();
|
||||
|
||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
||||
await hasuraPage.evaluate(() => {
|
||||
|
||||
2
dashboard/next-env.d.ts
vendored
2
dashboard/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "1.23.0",
|
||||
"version": "1.29.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -35,6 +35,7 @@
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@marsidev/react-turnstile": "^1.0.2",
|
||||
"@mui/base": "5.0.0-beta.31",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/system": "^5.15.14",
|
||||
@@ -65,7 +66,7 @@
|
||||
"graphql-ws": "^5.16.0",
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "^14.1.4",
|
||||
"next": "^14.2.10",
|
||||
"next-seo": "^6.5.0",
|
||||
"node-pg-format": "^1.3.5",
|
||||
"pluralize": "^8.0.0",
|
||||
@@ -165,7 +166,7 @@
|
||||
"tailwindcss": "^3.4.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.2.7",
|
||||
"vite": "^5.4.6",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
|
||||
@@ -10,10 +10,10 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
use: {
|
||||
|
||||
@@ -38,9 +38,12 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
const isProjectUpdating =
|
||||
currentProject?.appStates[0]?.stateId === ApplicationStatus.Updating;
|
||||
|
||||
const isProjectMigratingDatabase =
|
||||
currentProject?.appStates[0]?.stateId === ApplicationStatus.Migrating;
|
||||
|
||||
// Poll for project updates
|
||||
useEffect(() => {
|
||||
if (!isProjectUpdating) {
|
||||
if (!isProjectUpdating && !isProjectMigratingDatabase) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -51,7 +54,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isProjectUpdating, refetchProject]);
|
||||
}, [isProjectUpdating, isProjectMigratingDatabase, refetchProject]);
|
||||
|
||||
const openDevAssistant = () => {
|
||||
// The dev assistant can be only answer questions related to a particular project
|
||||
@@ -92,6 +95,13 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
{isProjectUpdating && (
|
||||
<Chip size="small" label="Updating" color="warning" />
|
||||
)}
|
||||
{isProjectMigratingDatabase && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Upgrading Postgres version"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-flow-col items-center gap-2 sm:grid">
|
||||
|
||||
@@ -63,6 +63,10 @@ export interface SettingsContainerProps
|
||||
* @default false
|
||||
*/
|
||||
showSwitch?: boolean;
|
||||
/**
|
||||
* Custom element to be rendered at the top-right corner of the section.
|
||||
*/
|
||||
topRightElement?: ReactNode;
|
||||
/**
|
||||
* Custom class names passed to the root element.
|
||||
*/
|
||||
@@ -108,6 +112,7 @@ export default function SettingsContainer({
|
||||
showSwitch = false,
|
||||
rootClassName,
|
||||
docsTitle,
|
||||
topRightElement,
|
||||
slotProps: { root, switch: switchSlot, submitButton, footer } = {},
|
||||
}: SettingsContainerProps) {
|
||||
return (
|
||||
@@ -137,6 +142,7 @@ export default function SettingsContainer({
|
||||
{description && <Text color="secondary">{description}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
{topRightElement}
|
||||
{!switchId && showSwitch && (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
|
||||
@@ -221,6 +221,13 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Custom Domains
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/rate-limiting"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Rate Limiting
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink href="/ai" exact={false} onClick={handleSelect}>
|
||||
AI
|
||||
</SettingsNavLink>
|
||||
|
||||
@@ -112,18 +112,24 @@ export default function ErrorToast({
|
||||
bounce: 0.1,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-between space-x-4">
|
||||
<button onClick={close} type="button" aria-label="Close">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-4">
|
||||
<button
|
||||
className="flex-shrink-0"
|
||||
onClick={close}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<span>
|
||||
<span className="flex-grow overflow-hidden break-words">
|
||||
{msg ?? 'An unkown error has occured, please try again later!'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className="flex flex-row items-center justify-center space-x-2 text-white"
|
||||
className="flex flex-shrink-0 flex-row items-center justify-center space-x-2 text-white"
|
||||
aria-label="Show error details"
|
||||
>
|
||||
<span>Info</span>
|
||||
{showInfo ? (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function RepeatIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
aria-label="Repeat"
|
||||
width="16"
|
||||
height="20"
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M11.4062 11.9779H15.9998L13.7035 8L11.4062 11.9779Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.1959 16.2243C13.1959 17.466 11.9655 18.4759 10.4525 18.4759L4.05328 18.4749C2.54037 18.4749 1.30989 17.2444 1.30989 15.7315V4.26843C1.30989 2.75552 2.54034 1.52504 4.05328 1.52504H10.4525C11.9654 1.52504 13.1959 2.535 13.1959 3.77661V6.53613C13.1959 6.81655 13.4235 7.04415 13.7039 7.04415C13.9844 7.04415 14.212 6.81655 14.212 6.53613V3.77557C14.212 1.97409 12.5253 0.508057 10.4526 0.508057L4.05333 0.509073C1.98056 0.509073 0.293945 2.19574 0.293945 4.26846V15.7326C0.293945 17.8054 1.98061 19.492 4.05333 19.492H10.4526C12.5253 19.492 14.212 18.0258 14.212 16.2245L14.212 13.5H13.1959L13.1959 16.2243Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
RepeatIcon.displayName = 'NhostRepeatIcon';
|
||||
|
||||
export default RepeatIcon;
|
||||
1
dashboard/src/components/ui/v2/icons/RepeatIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/RepeatIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as RepeatIcon } from './RepeatIcon';
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type ToggleConcealErrorsFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function ConcealErrorsSettings() {
|
||||
const { openDialog } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const form = useForm<ToggleConcealErrorsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled: data?.config?.auth?.misc?.concealErrors,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
form.reset({
|
||||
enabled: data?.config?.auth?.misc?.concealErrors,
|
||||
});
|
||||
}
|
||||
}, [loading, data, form]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading conceal error settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState } = form;
|
||||
|
||||
const handleToggleConcealErrors = async (
|
||||
values: ToggleConcealErrorsFormValues,
|
||||
) => {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
misc: {
|
||||
concealErrors: values.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfigPromise;
|
||||
form.reset(values);
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating conceal error settings...',
|
||||
successMessage: 'Conceal error settings updated successfully.',
|
||||
errorMessage:
|
||||
'Failed to update conceal error settings. Please try again.',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleToggleConcealErrors}>
|
||||
<SettingsContainer
|
||||
title="Conceal errors"
|
||||
description="If set, conceals sensitive error messages to prevent leaking information about user accounts."
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ConcealErrorsSettings } from './ConcealErrorsSettings';
|
||||
@@ -50,6 +50,9 @@ query GetAuthenticationSettings($appId: uuid!) {
|
||||
default
|
||||
}
|
||||
}
|
||||
misc {
|
||||
concealErrors
|
||||
}
|
||||
version
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useEstimatedDatabaseMigrationDowntime } from './useEstimatedDatabaseMigrationDowntime';
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useGetApplicationBackupsQuery,
|
||||
type GetApplicationBackupsQuery,
|
||||
type GetApplicationBackupsQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
|
||||
interface TimePeriod {
|
||||
value: number;
|
||||
unit: 'hours' | 'minutes';
|
||||
downtime: string;
|
||||
downtimeShort: string;
|
||||
}
|
||||
|
||||
export interface UseEstimatedDatabaseMigrationDowntimeOptions
|
||||
extends QueryHookOptions<
|
||||
GetApplicationBackupsQuery,
|
||||
GetApplicationBackupsQueryVariables
|
||||
> {}
|
||||
|
||||
const DEFAULT_ESTIMATED_DOWNTIME: TimePeriod = {
|
||||
value: 10,
|
||||
unit: 'minutes',
|
||||
downtime: '10 minutes',
|
||||
downtimeShort: '10min',
|
||||
};
|
||||
|
||||
function getEstimatedTime(diff: number): TimePeriod {
|
||||
if (diff > 1000 * 3600) {
|
||||
const value = Math.floor(diff / (1000 * 3600));
|
||||
const unitStr = value === 1 ? 'hour' : 'hours';
|
||||
return {
|
||||
value,
|
||||
unit: 'hours',
|
||||
downtime: `${value} ${unitStr}`,
|
||||
downtimeShort: `${value}hr`,
|
||||
};
|
||||
}
|
||||
// 10 minutes is the minimum estimated downtime
|
||||
if (diff > 1000 * 60 * 10) {
|
||||
const value = Math.floor(diff / (1000 * 60));
|
||||
const unitStr = value === 1 ? 'minute' : 'minutes';
|
||||
return {
|
||||
value,
|
||||
unit: 'minutes',
|
||||
downtime: `${value} ${unitStr}`,
|
||||
downtimeShort: `${value}min`,
|
||||
};
|
||||
}
|
||||
|
||||
return DEFAULT_ESTIMATED_DOWNTIME;
|
||||
}
|
||||
|
||||
/*
|
||||
* This hook returns the estimated downtime for a database migration.
|
||||
* The estimated downtime is calculated based on the time taken to complete the last backup.
|
||||
* If there are no backups, the estimated downtime is set to 10 minutes.
|
||||
*/
|
||||
|
||||
export default function useEstimatedDatabaseMigrationDowntime(
|
||||
options: UseEstimatedDatabaseMigrationDowntimeOptions = {},
|
||||
): TimePeriod {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isPlanFree = currentProject?.plan?.isFree;
|
||||
|
||||
const { data, loading, error } = useGetApplicationBackupsQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentProject?.id },
|
||||
skip: isPlanFree,
|
||||
});
|
||||
|
||||
if (loading || error) {
|
||||
return DEFAULT_ESTIMATED_DOWNTIME;
|
||||
}
|
||||
|
||||
const backups = data?.app?.backups;
|
||||
|
||||
let estimatedMilliseconds = 1000 * 60 * 10; // DEFAULT ESTIMATED DOWNTIME is 10 minutes
|
||||
|
||||
if (!isPlanFree && backups?.length > 0) {
|
||||
const lastBackup = backups[0];
|
||||
const createdAt = new Date(lastBackup.createdAt);
|
||||
const completedAt = new Date(lastBackup.completedAt);
|
||||
const diff = completedAt.valueOf() - createdAt.valueOf();
|
||||
estimatedMilliseconds = diff * 2;
|
||||
}
|
||||
|
||||
const estimated = getEstimatedTime(estimatedMilliseconds);
|
||||
|
||||
return estimated;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useGetPostgresVersion } from './useGetPostgresVersion';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { useGetPostgresSettingsQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
/**
|
||||
* Queries the postgres version of the current project.
|
||||
* @returns Major, minor and full version of the postgres database. Loading and error states.
|
||||
*/
|
||||
export default function useGetPostgresVersion() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const {
|
||||
data: postgresSettingsData,
|
||||
loading,
|
||||
error,
|
||||
} = useGetPostgresSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { version } = postgresSettingsData?.config?.postgres || {};
|
||||
const [postgresMajor, postgresMinor] = version?.split('.') || [
|
||||
undefined,
|
||||
undefined,
|
||||
];
|
||||
|
||||
return {
|
||||
version,
|
||||
postgresMajor,
|
||||
postgresMinor,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useIsDatabaseMigrating } from './useIsDatabaseMigrating';
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useGetApplicationStateQuery,
|
||||
type GetApplicationStateQuery,
|
||||
type GetApplicationStateQueryVariables,
|
||||
} from '@/generated/graphql';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useVisibilityChange } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseIsDatabaseMigratingOptions
|
||||
extends QueryHookOptions<
|
||||
GetApplicationStateQuery,
|
||||
GetApplicationStateQueryVariables
|
||||
> {
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* This hook returns information about the current state of database migration.
|
||||
* @param options - Options for the query.
|
||||
*
|
||||
* @returns - An object with two properties:
|
||||
* - isMigrating: true if the database is currently migrating.
|
||||
* - shouldShowUpgradeLogs: true if the database is currently migrating or the application is not live after a migration.
|
||||
*/
|
||||
export default function useIsDatabaseMigrating(
|
||||
options: UseIsDatabaseMigratingOptions = {},
|
||||
): {
|
||||
isMigrating: boolean;
|
||||
shouldShowUpgradeLogs: boolean;
|
||||
} {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isVisible = useVisibilityChange();
|
||||
|
||||
const {
|
||||
data: appStatesData,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useGetApplicationStateQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentProject?.id },
|
||||
skip: !currentProject,
|
||||
skipPollAttempt: () => !isVisible,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (options.shouldPoll) {
|
||||
startPolling(options.pollInterval || 5000);
|
||||
}
|
||||
|
||||
return () => stopPolling();
|
||||
}, [stopPolling, startPolling, options.shouldPoll, options.pollInterval]);
|
||||
|
||||
// Return true if the application is migrating or if the application is not live after a migration
|
||||
const shouldShowUpgradeLogs = (
|
||||
appStates: GetApplicationStateQuery['app']['appStates'],
|
||||
) => {
|
||||
for (let i = 0; i < appStates.length; i += 1) {
|
||||
if (appStates[i].stateId === ApplicationStatus.Live) {
|
||||
return false;
|
||||
}
|
||||
if (appStates[i].stateId === ApplicationStatus.Migrating) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Return true if the application is currently migrating
|
||||
const isMigrating = (
|
||||
appStates: GetApplicationStateQuery['app']['appStates'],
|
||||
) => {
|
||||
for (let i = 0; i < appStates.length; i += 1) {
|
||||
if (appStates[i].stateId === ApplicationStatus.Live) {
|
||||
return false;
|
||||
}
|
||||
if (appStates[i].stateId === ApplicationStatus.Errored) {
|
||||
return false;
|
||||
}
|
||||
if (appStates[i].stateId === ApplicationStatus.Migrating) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
isMigrating: isMigrating(appStatesData?.app?.appStates || []),
|
||||
shouldShowUpgradeLogs: shouldShowUpgradeLogs(
|
||||
appStatesData?.app?.appStates || [],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useMigrationLogs } from './useMigrationLogs';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useGetApplicationStateQuery,
|
||||
useGetSystemLogsQuery,
|
||||
type GetApplicationStateQuery,
|
||||
type GetApplicationStateQueryVariables,
|
||||
type GetSystemLogsQuery,
|
||||
type GetSystemLogsQueryVariables,
|
||||
} from '@/generated/graphql';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { ApolloError, QueryHookOptions } from '@apollo/client';
|
||||
import { useVisibilityChange } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseIsDatabaseMigratingOptions
|
||||
extends QueryHookOptions<
|
||||
GetApplicationStateQuery,
|
||||
GetApplicationStateQueryVariables
|
||||
> {
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMigrationLogsOptions
|
||||
extends QueryHookOptions<GetSystemLogsQuery, GetSystemLogsQueryVariables> {
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
export interface Log {
|
||||
level: string;
|
||||
msg: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns logs for the current database migration.
|
||||
* @param options - Options for the getSystemLogs query.
|
||||
* @returns - An object with three properties:
|
||||
* - logs: Logs for the current/latest database migration.
|
||||
* - loading: true if the getLogs query is in a loading state.
|
||||
* - error: Error object if the query failed.
|
||||
*/
|
||||
export default function useMigrationLogs(
|
||||
options: UseMigrationLogsOptions = {},
|
||||
): {
|
||||
logs: Partial<Log>[];
|
||||
loading: boolean;
|
||||
error: ApolloError;
|
||||
} {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isVisible = useVisibilityChange();
|
||||
|
||||
const { data: appStatesData } = useGetApplicationStateQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !currentProject,
|
||||
});
|
||||
|
||||
const migrationStartTimestamp = appStatesData?.app?.appStates?.find(
|
||||
(state) => state.stateId === ApplicationStatus.Migrating,
|
||||
)?.createdAt;
|
||||
|
||||
const from = new Date(migrationStartTimestamp);
|
||||
|
||||
const { data, loading, error, startPolling, stopPolling } =
|
||||
useGetSystemLogsQuery({
|
||||
...options,
|
||||
variables: {
|
||||
...options.variables,
|
||||
appID: currentProject.id,
|
||||
action: 'change-database-version',
|
||||
from,
|
||||
},
|
||||
skip: !currentProject || !from,
|
||||
skipPollAttempt: () => !isVisible,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (options.shouldPoll) {
|
||||
startPolling(options.pollInterval || 5000);
|
||||
}
|
||||
|
||||
return () => stopPolling();
|
||||
}, [stopPolling, startPolling, options.shouldPoll, options.pollInterval]);
|
||||
|
||||
const systemLogs = data?.systemLogs ?? [];
|
||||
const sortedLogs = [...systemLogs];
|
||||
sortedLogs.sort(
|
||||
(a, b) => new Date(a.timestamp).valueOf() - new Date(b.timestamp).valueOf(),
|
||||
); // sort in ascending order
|
||||
|
||||
const logs = sortedLogs.map(({ log }) => {
|
||||
let logObj: Partial<Log> = {};
|
||||
try {
|
||||
logObj = JSON.parse(log);
|
||||
return logObj;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log', log);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { useAutocomplete } from '@mui/base/useAutocomplete';
|
||||
import type { AutocompleteRenderGroupParams } from '@mui/material/Autocomplete';
|
||||
import { autocompleteClasses } from '@mui/material/Autocomplete';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ForwardedRef,
|
||||
HTMLAttributes,
|
||||
PropsWithoutRef,
|
||||
@@ -209,6 +210,7 @@ function ColumnAutocomplete(
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
const options = useColumnGroups({
|
||||
selectedSchema,
|
||||
selectedTable,
|
||||
@@ -241,6 +243,33 @@ function ColumnAutocomplete(
|
||||
onChange: handleChange,
|
||||
});
|
||||
|
||||
|
||||
function handleInputValueChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
const {value} = event.target
|
||||
setInputValue(value)
|
||||
|
||||
setSelectedColumn(
|
||||
{
|
||||
value,
|
||||
label: value,
|
||||
metadata: selectedColumn?.metadata || {
|
||||
table_schema: selectedSchema,
|
||||
table_name: selectedTable,
|
||||
}
|
||||
});
|
||||
|
||||
onChange?.(event, {
|
||||
value:
|
||||
selectedRelationships.length > 0
|
||||
? [relationshipDotNotation, value].join('.')
|
||||
: value,
|
||||
columnMetadata: {
|
||||
table_schema: selectedSchema,
|
||||
table_name: selectedTable,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...getRootProps()} className={rootClassName}>
|
||||
@@ -293,7 +322,7 @@ function ColumnAutocomplete(
|
||||
helperText={
|
||||
String(tableError || metadataError || '') || props.helperText
|
||||
}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
onChange={handleInputValueChange}
|
||||
value={inputValue}
|
||||
startAdornment={
|
||||
selectedColumn || relationshipDotNotation ? (
|
||||
@@ -305,7 +334,7 @@ function ColumnAutocomplete(
|
||||
className="!ml-2 flex-shrink-0 truncate lg:max-w-[200px]"
|
||||
>
|
||||
<Text component="span" color="disabled">
|
||||
{defaultTable}
|
||||
{selectedTable}
|
||||
</Text>
|
||||
.
|
||||
{relationshipDotNotation && (
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Chip } from '@/components/ui/v2/Chip';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { LockIcon } from '@/components/ui/v2/icons/LockIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
@@ -20,7 +19,6 @@ import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TerminalIcon } from '@/components/ui/v2/icons/TerminalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UsersIcon } from '@/components/ui/v2/icons/UsersIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
@@ -312,15 +310,6 @@ function DataBrowserSidebarContent({
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid items-center justify-start grid-flow-col gap-1"
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
{!isSelectedSchemaLocked && (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
export default function DatabaseMigrateWarning() {
|
||||
return (
|
||||
<Alert severity="error" className="flex flex-col gap-3 text-left">
|
||||
<Text
|
||||
className="flex items-center gap-1 font-semibold"
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" /> Error: Database version upgrade not
|
||||
possible
|
||||
</Text>
|
||||
<Text
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
}}
|
||||
>
|
||||
Your project isn't currently in a healthy state. Please, review
|
||||
before proceeding with the upgrade.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseMigrateDisabledError } from './DatabaseMigrateDisabledError';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useEstimatedDatabaseMigrationDowntime } from '@/features/database/common/hooks/useEstimatedDatabaseMigrationDowntime';
|
||||
|
||||
export default function DatabaseMigrateDowntimeWarning() {
|
||||
const { downtimeShort } = useEstimatedDatabaseMigrationDowntime();
|
||||
|
||||
return (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||
<Text className="flex items-start gap-1 font-semibold">
|
||||
<span>⚠</span> Warning: upgrading Postgres major version
|
||||
</Text>
|
||||
<div className="flex">
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'beige.main',
|
||||
}}
|
||||
className="py-1/2 flex items-center justify-center text-nowrap rounded-full px-2 font-semibold"
|
||||
>
|
||||
Estimated downtime ~{downtimeShort}
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text>
|
||||
Upgrading a major version of Postgres requires downtime. The amount of
|
||||
downtime will depend on your database size, so plan ahead in order to
|
||||
reduce the impact on your users.
|
||||
</Text>
|
||||
<Text>
|
||||
Note that it isn't possible to downgrade between major versions.
|
||||
</Text>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseMigrateDowntimeWarning } from './DatabaseMigrateDowntimeWarning';
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useMigrationLogs } from '@/features/database/common/hooks/useMigrationLogs';
|
||||
|
||||
export default function DatabaseMigrateLogsModal() {
|
||||
const { logs, loading, error } = useMigrationLogs({
|
||||
shouldPoll: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box className="pt-2">
|
||||
<Box
|
||||
className="min-h-80 p-4"
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.300' : 'grey.700',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-mono"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100',
|
||||
}}
|
||||
>
|
||||
Could not fetch logs. Error: {error.message}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box className="pt-2">
|
||||
<Box
|
||||
className="min-h-80 p-4"
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.300' : 'grey.700',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-mono"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100',
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<Box className="pt-2">
|
||||
<Box
|
||||
className="min-h-80 p-4"
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.300' : 'grey.700',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-mono"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100',
|
||||
}}
|
||||
>
|
||||
No logs found
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="pt-2">
|
||||
<Box
|
||||
className="min-h-80 p-4"
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.300' : 'grey.700',
|
||||
}}
|
||||
>
|
||||
{logs.map((logObj) => {
|
||||
if (logObj?.level && logObj?.msg) {
|
||||
return (
|
||||
<Text
|
||||
key={`${logObj.msg}${logObj.time}`}
|
||||
className="font-mono"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100',
|
||||
}}
|
||||
>
|
||||
{logObj.level.toUpperCase()}: {logObj.msg}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseMigrateLogsModal } from './DatabaseMigrateLogsModal';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useEstimatedDatabaseMigrationDowntime } from '@/features/database/common/hooks/useEstimatedDatabaseMigrationDowntime';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
GetPostgresSettingsDocument,
|
||||
GetWorkspaceAndProjectDocument,
|
||||
useUpdateDatabaseVersionMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DatabaseMigrateVersionConfirmationDialogProps {
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Function to be called when the user clicks the proceed button.
|
||||
*/
|
||||
onProceed: () => void;
|
||||
/**
|
||||
* New version to migrate to.
|
||||
*/
|
||||
postgresVersion: string;
|
||||
}
|
||||
|
||||
export default function DatabaseMigrateVersionConfirmationDialog({
|
||||
onCancel,
|
||||
onProceed,
|
||||
postgresVersion,
|
||||
}: DatabaseMigrateVersionConfirmationDialogProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updatePostgresMajor] = useUpdateDatabaseVersionMutation({
|
||||
refetchQueries: [
|
||||
GetPostgresSettingsDocument,
|
||||
GetWorkspaceAndProjectDocument,
|
||||
],
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { downtime } = useEstimatedDatabaseMigrationDowntime({
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updatePostgresMajor({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
version: postgresVersion,
|
||||
},
|
||||
});
|
||||
|
||||
onProceed();
|
||||
closeDialog();
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating postgres version...',
|
||||
successMessage: 'Major version upgrade started.',
|
||||
errorMessage:
|
||||
'An error occurred while updating the database version. Please try again later.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 pt-0 text-left')}>
|
||||
<div className="grid grid-flow-row gap-6">
|
||||
<Text>
|
||||
The upgrade process will require an{' '}
|
||||
<span className="font-semibold">
|
||||
estimated {downtime} of downtime
|
||||
</span>
|
||||
. To continue with the upgrade process, click on "Proceed".
|
||||
</Text>
|
||||
|
||||
<div className="grid grid-flow-col gap-4">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleClick} loading={loading}>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseMigrateVersionConfirmationDialog } from './DatabaseMigrateVersionConfirmationDialog';
|
||||
@@ -5,28 +5,46 @@ import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { RepeatIcon } from '@/components/ui/v2/icons/RepeatIcon';
|
||||
import { useGetPostgresVersion } from '@/features/database/common/hooks/useGetPostgresVersion';
|
||||
import { useIsDatabaseMigrating } from '@/features/database/common/hooks/useIsDatabaseMigrating';
|
||||
import { DatabaseMigrateDisabledError } from '@/features/database/settings/components/DatabaseMigrateDisabledError';
|
||||
import { DatabaseMigrateDowntimeWarning } from '@/features/database/settings/components/DatabaseMigrateDowntimeWarning';
|
||||
import { DatabaseMigrateLogsModal } from '@/features/database/settings/components/DatabaseMigrateLogsModal';
|
||||
import { DatabaseMigrateVersionConfirmationDialog } from '@/features/database/settings/components/DatabaseMigrateVersionConfirmationDialog';
|
||||
import { DatabaseUpdateInProgressWarning } from '@/features/database/settings/components/DatabaseUpdateInProgressWarning';
|
||||
import { useAppState } from '@/features/projects/common/hooks/useAppState';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
GetPostgresSettingsDocument,
|
||||
GetWorkspaceAndProjectDocument,
|
||||
Software_Type_Enum,
|
||||
useGetPostgresSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
majorVersion: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
value: Yup.string().required('Major version is a required field'),
|
||||
})
|
||||
.label('Postgres Version')
|
||||
.label('Postgres major version')
|
||||
.required(),
|
||||
minorVersion: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required('Minor version is a required field'),
|
||||
})
|
||||
.label('Postgres minor version')
|
||||
.required(),
|
||||
});
|
||||
|
||||
@@ -34,21 +52,31 @@ export type DatabaseServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
type DatabaseServiceField = Required<
|
||||
Yup.InferType<typeof validationSchema>['majorVersion']
|
||||
>;
|
||||
|
||||
export default function DatabaseServiceVersionSettings() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { openDialog } = useDialog();
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetPostgresSettingsDocument],
|
||||
refetchQueries: [
|
||||
GetPostgresSettingsDocument,
|
||||
GetWorkspaceAndProjectDocument,
|
||||
],
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetPostgresSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
const {
|
||||
version: postgresVersion,
|
||||
postgresMajor: currentPostgresMajor,
|
||||
postgresMinor: currentPostgresMinor,
|
||||
error: postgresSettingsError,
|
||||
loading: loadingPostgresSettings,
|
||||
} = useGetPostgresVersion();
|
||||
|
||||
const { data: databaseVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
@@ -57,14 +85,11 @@ export default function DatabaseServiceVersionSettings() {
|
||||
skip: !isPlatform,
|
||||
});
|
||||
|
||||
const { version } = data?.config?.postgres || {};
|
||||
|
||||
const databaseVersions = databaseVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(databaseVersions.map((el) => el.version)).add(version),
|
||||
new Set(databaseVersions.map((el) => el.version)).add(postgresVersion),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((availableVersion) => ({
|
||||
label: availableVersion,
|
||||
value: availableVersion,
|
||||
@@ -72,46 +97,140 @@ export default function DatabaseServiceVersionSettings() {
|
||||
|
||||
const form = useForm<DatabaseServiceVersionFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { version: { label: '', value: '' } },
|
||||
defaultValues: {
|
||||
minorVersion: { label: '', value: '' },
|
||||
majorVersion: { label: '', value: '' },
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { formState, watch } = form;
|
||||
|
||||
const selectedMajor = watch('majorVersion').value;
|
||||
const selectedMinor = watch('minorVersion').value;
|
||||
|
||||
const getMajorAndMinorVersions = (): {
|
||||
availableMajorVersions: DatabaseServiceField[];
|
||||
majorToMinorVersions: Record<string, DatabaseServiceField[]>;
|
||||
} => {
|
||||
const majorToMinorVersions = {};
|
||||
const availableMajorVersions = [];
|
||||
availableVersions.forEach((availableVersion) => {
|
||||
if (!availableVersion.value) {
|
||||
return;
|
||||
}
|
||||
const [major, minor] = availableVersion.value.split('.');
|
||||
|
||||
// Don't suggest versions that are lower than the current Postgres major version (can't downgrade)
|
||||
if (Number(major) < Number(currentPostgresMajor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableMajorVersions.every((item) => item.value !== major)) {
|
||||
availableMajorVersions.push({
|
||||
label: major,
|
||||
value: major,
|
||||
});
|
||||
}
|
||||
|
||||
if (!majorToMinorVersions[major]) {
|
||||
majorToMinorVersions[major] = [];
|
||||
}
|
||||
|
||||
majorToMinorVersions[major].push({
|
||||
label: minor,
|
||||
value: minor,
|
||||
});
|
||||
});
|
||||
return {
|
||||
availableMajorVersions,
|
||||
majorToMinorVersions,
|
||||
};
|
||||
};
|
||||
|
||||
const { availableMajorVersions, majorToMinorVersions } = useMemo(
|
||||
getMajorAndMinorVersions,
|
||||
[availableVersions, currentPostgresMajor],
|
||||
);
|
||||
const availableMinorVersions = majorToMinorVersions[selectedMajor] || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && version) {
|
||||
if (
|
||||
!loadingPostgresSettings &&
|
||||
currentPostgresMajor &&
|
||||
currentPostgresMinor
|
||||
) {
|
||||
form.reset({
|
||||
version: {
|
||||
label: version,
|
||||
value: version,
|
||||
majorVersion: {
|
||||
label: currentPostgresMajor,
|
||||
value: currentPostgresMajor,
|
||||
},
|
||||
minorVersion: {
|
||||
label: currentPostgresMinor,
|
||||
value: currentPostgresMinor,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [loading, version, form]);
|
||||
}, [
|
||||
loadingPostgresSettings,
|
||||
currentPostgresMajor,
|
||||
currentPostgresMinor,
|
||||
form,
|
||||
]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Postgres version..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { isMigrating, shouldShowUpgradeLogs } = useIsDatabaseMigrating({
|
||||
shouldPoll: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
const showMigrateWarning =
|
||||
Number(selectedMajor) > Number(currentPostgresMajor);
|
||||
|
||||
const { formState } = form;
|
||||
const { state } = useAppState();
|
||||
const applicationUpdating =
|
||||
state === ApplicationStatus.Updating ||
|
||||
state === ApplicationStatus.Migrating;
|
||||
const applicationUnhealthy =
|
||||
state !== ApplicationStatus.Live && !applicationUpdating;
|
||||
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
||||
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
||||
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
||||
const versionFieldsDisabled =
|
||||
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
||||
const saveDisabled = versionFieldsDisabled || !isDirty;
|
||||
|
||||
const handleDatabaseServiceVersionsChange = async (
|
||||
formValues: DatabaseServiceVersionFormValues,
|
||||
) => {
|
||||
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
||||
|
||||
// Major version change
|
||||
if (isMajorVersionDirty) {
|
||||
openDialog({
|
||||
title: 'Update Postgres MAJOR version',
|
||||
component: (
|
||||
<DatabaseMigrateVersionConfirmationDialog
|
||||
postgresVersion={newVersion}
|
||||
onCancel={() => {}}
|
||||
onProceed={() => {}}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Minor version change
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
postgres: {
|
||||
version: formValues.version.value,
|
||||
version: newVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -143,6 +262,33 @@ export default function DatabaseServiceVersionSettings() {
|
||||
);
|
||||
};
|
||||
|
||||
const openLatestUpgradeLogsModal = async () => {
|
||||
openDialog({
|
||||
component: <DatabaseMigrateLogsModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-2xl w-full' },
|
||||
titleProps: {
|
||||
onClose: closeDialog,
|
||||
},
|
||||
},
|
||||
title: 'Postgres upgrade logs',
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingPostgresSettings) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Postgres version..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (postgresSettingsError) {
|
||||
throw postgresSettingsError;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleDatabaseServiceVersionsChange}>
|
||||
@@ -151,54 +297,144 @@ export default function DatabaseServiceVersionSettings() {
|
||||
description="The version of Postgres to use."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
disabled: saveDisabled,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://hub.docker.com/r/nhost/postgres/tags"
|
||||
docsTitle="the latest releases"
|
||||
className="grid grid-flow-row px-4 gap-x-4 gap-y-2 lg:grid-cols-5"
|
||||
className="flex flex-col"
|
||||
topRightElement={
|
||||
shouldShowUpgradeLogs ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="medium"
|
||||
className="self-center"
|
||||
onClick={openLatestUpgradeLogsModal}
|
||||
startIcon={<RepeatIcon className="h-4 w-4" />}
|
||||
>
|
||||
View latest upgrade logs
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
autoHighlight
|
||||
freeSolo
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option || '';
|
||||
}
|
||||
|
||||
return option.value;
|
||||
}}
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
const optionLabelLower = option.label.toLowerCase();
|
||||
|
||||
if (optionLabelLower.startsWith(inputValueLower)) {
|
||||
matched.push(option);
|
||||
} else {
|
||||
otherOptions.push(option);
|
||||
<Box className="grid grid-flow-row gap-x-4 gap-y-2 lg:grid-cols-5">
|
||||
<ControlledAutocomplete
|
||||
id="majorVersion"
|
||||
name="majorVersion"
|
||||
autoHighlight
|
||||
freeSolo
|
||||
disabled={versionFieldsDisabled}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option || '';
|
||||
}
|
||||
});
|
||||
|
||||
const result = [...matched, ...otherOptions];
|
||||
return option.value;
|
||||
}}
|
||||
showCustomOption="auto"
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
return result;
|
||||
}}
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
options={availableVersions}
|
||||
error={!!formState.errors?.version?.message}
|
||||
helperText={formState.errors?.version?.message}
|
||||
showCustomOption="auto"
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
options.forEach((option) => {
|
||||
const optionLabelLower = option.label.toLowerCase();
|
||||
|
||||
if (optionLabelLower.startsWith(inputValueLower)) {
|
||||
matched.push(option);
|
||||
} else {
|
||||
otherOptions.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
const result = [...matched, ...otherOptions];
|
||||
|
||||
return result;
|
||||
}}
|
||||
onChange={(_event, value) => {
|
||||
if (typeof value !== 'string' && !Array.isArray(value)) {
|
||||
if (value.value !== selectedMajor) {
|
||||
const nextAvailableMinorVersions =
|
||||
majorToMinorVersions[value.value] || [];
|
||||
|
||||
const isSelectedMinorAvailable =
|
||||
nextAvailableMinorVersions.some(
|
||||
(minor) => minor.value === selectedMinor,
|
||||
);
|
||||
|
||||
// If the selected minor version is not available in the new major version, select the first available minor version
|
||||
if (
|
||||
!isSelectedMinorAvailable &&
|
||||
nextAvailableMinorVersions.length > 0
|
||||
) {
|
||||
form.setValue(
|
||||
'minorVersion',
|
||||
nextAvailableMinorVersions[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
form.setValue('majorVersion', value);
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
className="lg:col-span-1"
|
||||
label="MAJOR"
|
||||
options={availableMajorVersions}
|
||||
error={!!formState.errors?.majorVersion?.value?.message}
|
||||
helperText={formState.errors?.majorVersion?.value?.message}
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
id="minorVersion"
|
||||
name="minorVersion"
|
||||
autoHighlight
|
||||
freeSolo
|
||||
disabled={versionFieldsDisabled}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option || '';
|
||||
}
|
||||
|
||||
return option.value;
|
||||
}}
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
const optionLabelLower = option.label.toLowerCase();
|
||||
|
||||
if (optionLabelLower.startsWith(inputValueLower)) {
|
||||
matched.push(option);
|
||||
} else {
|
||||
otherOptions.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
const result = [...matched, ...otherOptions];
|
||||
|
||||
return result;
|
||||
}}
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
label="MINOR"
|
||||
options={availableMinorVersions}
|
||||
error={!!formState.errors?.minorVersion?.value?.message}
|
||||
helperText={formState.errors?.minorVersion?.value?.message}
|
||||
showCustomOption="auto"
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
</Box>
|
||||
{showMigrateWarning && <DatabaseMigrateDowntimeWarning />}
|
||||
{applicationUpdating && <DatabaseUpdateInProgressWarning />}
|
||||
{applicationUnhealthy && !isMigrating && (
|
||||
<DatabaseMigrateDisabledError />
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { ClockIcon } from '@/components/ui/v2/icons/ClockIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
export default function DatabaseMigrateWarning() {
|
||||
return (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<Text className="flex items-center gap-1 font-semibold">
|
||||
<ClockIcon className="h-4 w-4" /> An update is in progress
|
||||
</Text>
|
||||
<Text>You can edit the version only after the update is complete.</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseUpdateInProgressWarning } from './DatabaseUpdateInProgressWarning';
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Autocomplete } from '@/components/ui/v2/Autocomplete';
|
||||
import { DEFAULT_ROLES } from '@/features/graphql/common/utils/constants';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { RemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useRemoteAppGetUsersCustomLazyQuery,
|
||||
type RemoteAppGetUsersCustomQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { debounce } from '@mui/material/utils';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface UserSelectProps {
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ export interface UserSelectProps {
|
||||
*/
|
||||
onUserChange: (userId: string, availableRoles?: string[]) => void;
|
||||
/**
|
||||
* Class name to be applied to the `<Select />` element.
|
||||
* Class name to be applied to the `<Autocomplete />` element.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
@@ -22,35 +23,87 @@ export default function UserSelect({
|
||||
onUserChange,
|
||||
...props
|
||||
}: UserSelectProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||
const { data, loading, error } = useRemoteAppGetUsersCustomQuery({
|
||||
|
||||
const [fetchAppUsers, { loading }] = useRemoteAppGetUsersCustomLazyQuery({
|
||||
client: userApplicationClient,
|
||||
variables: { where: {}, limit: 250, offset: 0 },
|
||||
skip: !currentProject,
|
||||
variables: {
|
||||
where: {},
|
||||
limit: 250,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<ActivityIndicator label="Loading users..." delay={500} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const fetchUsers = useCallback(
|
||||
async (
|
||||
request: { input: string },
|
||||
callback: (results?: RemoteAppGetUsersCustomQuery['users']) => void,
|
||||
) => {
|
||||
const ilike = `%${request.input === 'Admin' ? '' : request.input}%`;
|
||||
const { data } = await fetchAppUsers({
|
||||
client: userApplicationClient,
|
||||
variables: {
|
||||
where: {
|
||||
displayName: { _ilike: ilike },
|
||||
},
|
||||
limit: 250,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
callback(data?.users);
|
||||
},
|
||||
[fetchAppUsers, userApplicationClient],
|
||||
);
|
||||
|
||||
const fetchOptions = useMemo(() => debounce(fetchUsers, 1000), [fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions({ input: inputValue }, (results) => {
|
||||
if (active || inputValue === '') {
|
||||
setUsers(results);
|
||||
}
|
||||
});
|
||||
}, [inputValue, fetchOptions, active]);
|
||||
|
||||
const autocompleteOptions = [
|
||||
{
|
||||
value: 'admin',
|
||||
label: 'Admin',
|
||||
group: 'Admin',
|
||||
},
|
||||
...users.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.displayName,
|
||||
group: 'Users',
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
<Autocomplete
|
||||
{...props}
|
||||
id="user-select"
|
||||
label="Make Request As"
|
||||
hideEmptyHelperText
|
||||
defaultValue="admin"
|
||||
slotProps={{ root: { className: 'truncate' } }}
|
||||
onChange={(_event, userId) => {
|
||||
label="Make request as"
|
||||
options={autocompleteOptions}
|
||||
defaultValue={{
|
||||
value: 'admin',
|
||||
label: 'Admin',
|
||||
group: 'Admin',
|
||||
}}
|
||||
autoComplete
|
||||
fullWidth
|
||||
autoSelect
|
||||
groupBy={(option) => option.group}
|
||||
autoHighlight
|
||||
includeInputInList
|
||||
loading={loading}
|
||||
onChange={(_event, _value, reason, details) => {
|
||||
setActive(false);
|
||||
const userId = details.option.value;
|
||||
if (typeof userId !== 'string') {
|
||||
return;
|
||||
}
|
||||
@@ -61,22 +114,23 @@ export default function UserSelect({
|
||||
return;
|
||||
}
|
||||
|
||||
const user: RemoteAppGetUsersCustomQuery['users'][0] = data?.users.find(
|
||||
const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find(
|
||||
({ id }) => id === userId,
|
||||
);
|
||||
|
||||
const roles = user?.roles.map(({ role }) => role);
|
||||
const roles = user?.roles?.map(({ role }) => role);
|
||||
|
||||
onUserChange(user.id, roles);
|
||||
onUserChange(userId, roles ?? DEFAULT_ROLES);
|
||||
|
||||
fetchUsers({ input: '' }, (results) => {
|
||||
if (results) {
|
||||
setUsers(results);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Option value="admin">Admin</Option>
|
||||
|
||||
{data?.users.map(({ id, displayName, email, phoneNumber }) => (
|
||||
<Option key={id} value={id}>
|
||||
{displayName || email || phoneNumber || id}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
onInputChange={(event, newInputValue) => {
|
||||
setInputValue(newInputValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
1
dashboard/src/features/graphql/common/hooks/index.ts
Normal file
1
dashboard/src/features/graphql/common/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useGetAppUsers } from './useGetAppUsers';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type {
|
||||
RemoteAppGetUsersCustomQuery,
|
||||
RemoteAppGetUsersCustomQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
|
||||
export type UseFilesOptions = {
|
||||
searchString?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/**
|
||||
* Custom options for the query.
|
||||
*/
|
||||
options?: QueryHookOptions<
|
||||
RemoteAppGetUsersCustomQuery,
|
||||
RemoteAppGetUsersCustomQueryVariables
|
||||
>;
|
||||
};
|
||||
|
||||
export default function useGetAppUsers({
|
||||
searchString,
|
||||
limit = 250,
|
||||
offset = 0,
|
||||
options = {},
|
||||
}: UseFilesOptions) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||
const { data, error, loading } = useRemoteAppGetUsersCustomQuery({
|
||||
...options,
|
||||
client: userApplicationClient,
|
||||
variables: {
|
||||
...options.variables,
|
||||
where: searchString
|
||||
? {
|
||||
displayName: { _ilike: `%${searchString}%` },
|
||||
}
|
||||
: {},
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
skip: !currentProject,
|
||||
});
|
||||
|
||||
const users = data?.users || [];
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default function ApplicationLockedReason({
|
||||
Please{' '}
|
||||
<Link
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href="mailto:support@nhost.io"
|
||||
href="/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { BaseDialog } from '@/components/ui/v2/Dialog';
|
||||
import { Radio } from '@/components/ui/v2/Radio';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useAppState } from '@/features/projects/common/hooks/useAppState';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
@@ -31,7 +31,7 @@ function Plan({ planName, price, setPlan, planId, selectedPlanId }: any) {
|
||||
>
|
||||
<div className="grid grid-flow-row gap-y-0.5">
|
||||
<div className="grid grid-flow-col items-center justify-start gap-2">
|
||||
<Checkbox
|
||||
<Radio
|
||||
onChange={setPlan}
|
||||
checked={selectedPlanId === planId}
|
||||
aria-label={planName}
|
||||
@@ -241,7 +241,21 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-flow-row gap-2">
|
||||
<div className="mt-0">
|
||||
<Text variant="subtitle2" className="w-full px-1">
|
||||
For a complete list of features, visit our{' '}
|
||||
<a
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
pricing page
|
||||
</a>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-flow-row gap-2">
|
||||
<Button
|
||||
onClick={handleChangePlanClick}
|
||||
disabled={!selectedPlan}
|
||||
|
||||
@@ -38,6 +38,13 @@ export default function useNavigationVisible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
state === ApplicationStatus.Migrating &&
|
||||
currentProject.desiredState === ApplicationStatus.Live
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
state === ApplicationStatus.Live ||
|
||||
state === ApplicationStatus.Updating
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const planDescriptions = {
|
||||
Starter: '1 GB database, 5 GB of file storage, 10 GB of network traffic.',
|
||||
Pro: '10 GB database, 25 GB of file storage, 50 GB of network traffic, and backups.',
|
||||
Pro: '10 GB database, 50 GB of file storage, 50 GB of network traffic, and backups.',
|
||||
Team: 'Reach out to us at support@nhost.io to have your private channel set up.',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { rateLimitingItemValidationSchema } from '@/features/projects/rate-limiting/settings/components/validationSchemas';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUpdateRateLimitConfigMutation } from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { RateLimitField } from 'features/projects/rate-limiting/settings/components/RateLimitField';
|
||||
import { useGetRateLimits } from 'features/projects/rate-limiting/settings/hooks/useGetRateLimits';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
bruteForce: rateLimitingItemValidationSchema,
|
||||
emails: rateLimitingItemValidationSchema,
|
||||
global: rateLimitingItemValidationSchema,
|
||||
signups: rateLimitingItemValidationSchema,
|
||||
sms: rateLimitingItemValidationSchema,
|
||||
});
|
||||
|
||||
export type AuthLimitingFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AuthLimitingForm() {
|
||||
const { openDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
const [updateRateLimitConfig] = useUpdateRateLimitConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { authRateLimit, loading } = useGetRateLimits();
|
||||
const {
|
||||
bruteForce,
|
||||
emails,
|
||||
global,
|
||||
signups,
|
||||
sms,
|
||||
enabled: authRateEnabled,
|
||||
} = authRateLimit;
|
||||
|
||||
const {
|
||||
limit: bruteForceLimit,
|
||||
interval: bruteForceInterval,
|
||||
intervalUnit: bruteForceIntervalUnit,
|
||||
} = bruteForce;
|
||||
const {
|
||||
limit: emailsLimit,
|
||||
interval: emailsInterval,
|
||||
intervalUnit: emailsIntervalUnit,
|
||||
} = emails;
|
||||
const {
|
||||
limit: globalLimit,
|
||||
interval: globalInterval,
|
||||
intervalUnit: globalIntervalUnit,
|
||||
} = global;
|
||||
const {
|
||||
limit: signupsLimit,
|
||||
interval: signupsInterval,
|
||||
intervalUnit: signupsIntervalUnit,
|
||||
} = signups;
|
||||
const {
|
||||
limit: smsLimit,
|
||||
interval: smsInterval,
|
||||
intervalUnit: smsIntervalUnit,
|
||||
} = sms;
|
||||
|
||||
const form = useForm<AuthLimitingFormValues>({
|
||||
defaultValues: {
|
||||
enabled: authRateEnabled,
|
||||
bruteForce: {
|
||||
limit: bruteForceLimit,
|
||||
interval: bruteForceInterval,
|
||||
intervalUnit: bruteForceIntervalUnit,
|
||||
},
|
||||
emails: {
|
||||
limit: emailsLimit,
|
||||
interval: emailsInterval,
|
||||
intervalUnit: emailsIntervalUnit,
|
||||
},
|
||||
global: {
|
||||
limit: globalLimit,
|
||||
interval: globalInterval,
|
||||
intervalUnit: globalIntervalUnit,
|
||||
},
|
||||
signups: {
|
||||
limit: signupsLimit,
|
||||
interval: signupsInterval,
|
||||
intervalUnit: signupsIntervalUnit,
|
||||
},
|
||||
sms: {
|
||||
limit: smsLimit,
|
||||
interval: smsInterval,
|
||||
intervalUnit: smsIntervalUnit,
|
||||
},
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && authRateEnabled) {
|
||||
form.reset({
|
||||
enabled: authRateEnabled,
|
||||
bruteForce: {
|
||||
limit: bruteForceLimit,
|
||||
interval: bruteForceInterval,
|
||||
intervalUnit: bruteForceIntervalUnit,
|
||||
},
|
||||
emails: {
|
||||
limit: emailsLimit,
|
||||
interval: emailsInterval,
|
||||
intervalUnit: emailsIntervalUnit,
|
||||
},
|
||||
global: {
|
||||
limit: globalLimit,
|
||||
interval: globalInterval,
|
||||
intervalUnit: globalIntervalUnit,
|
||||
},
|
||||
signups: {
|
||||
limit: signupsLimit,
|
||||
interval: signupsInterval,
|
||||
intervalUnit: signupsIntervalUnit,
|
||||
},
|
||||
sms: {
|
||||
limit: smsLimit,
|
||||
interval: smsInterval,
|
||||
intervalUnit: smsIntervalUnit,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
loading,
|
||||
form,
|
||||
authRateEnabled,
|
||||
bruteForceLimit,
|
||||
bruteForceInterval,
|
||||
bruteForceIntervalUnit,
|
||||
emailsLimit,
|
||||
emailsInterval,
|
||||
emailsIntervalUnit,
|
||||
globalLimit,
|
||||
globalInterval,
|
||||
globalIntervalUnit,
|
||||
signupsLimit,
|
||||
signupsInterval,
|
||||
signupsIntervalUnit,
|
||||
smsLimit,
|
||||
smsInterval,
|
||||
smsIntervalUnit,
|
||||
]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading rate limits..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
formState,
|
||||
watch,
|
||||
} = form;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const handleSubmit = async (formValues: AuthLimitingFormValues) => {
|
||||
const updateConfigPromise = updateRateLimitConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
rateLimit: formValues.enabled
|
||||
? {
|
||||
bruteForce: {
|
||||
limit: formValues.bruteForce.limit,
|
||||
interval: `${formValues.bruteForce.interval}${formValues.bruteForce.intervalUnit}`,
|
||||
},
|
||||
emails: {
|
||||
limit: formValues.emails.limit,
|
||||
interval: `${formValues.emails.interval}${formValues.emails.intervalUnit}`,
|
||||
},
|
||||
global: {
|
||||
limit: formValues.global.limit,
|
||||
interval: `${formValues.global.interval}${formValues.global.intervalUnit}`,
|
||||
},
|
||||
signups: {
|
||||
limit: formValues.signups.limit,
|
||||
interval: `${formValues.signups.interval}${formValues.signups.intervalUnit}`,
|
||||
},
|
||||
sms: {
|
||||
limit: formValues.sms.limit,
|
||||
interval: `${formValues.sms.interval}${formValues.sms.intervalUnit}`,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfigPromise;
|
||||
form.reset(formValues);
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating Auth rate limit settings...',
|
||||
successMessage: 'Auth rate limit settings updated successfully',
|
||||
errorMessage: 'Failed to update Auth rate limit settings',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<SettingsContainer
|
||||
title="Auth"
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col px-0"
|
||||
>
|
||||
<Divider />
|
||||
<RateLimitField
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.bruteForce}
|
||||
id="bruteForce"
|
||||
title="Brute Force"
|
||||
/>
|
||||
<Divider />
|
||||
<RateLimitField
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.emails}
|
||||
id="emails"
|
||||
title="Emails"
|
||||
/>
|
||||
<Divider />
|
||||
<RateLimitField
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.global}
|
||||
id="global"
|
||||
title="Global"
|
||||
/>
|
||||
<Divider />
|
||||
<RateLimitField
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.signups}
|
||||
id="signups"
|
||||
title="Signups"
|
||||
/>
|
||||
<Divider />
|
||||
<RateLimitField
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.sms}
|
||||
id="sms"
|
||||
title="SMS"
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AuthLimitingForm } from './AuthLimitingForm';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { intervalUnitOptions } from '@/features/projects/rate-limiting/settings/components/validationSchemas';
|
||||
import type {
|
||||
FieldError,
|
||||
FieldErrorsImpl,
|
||||
Merge,
|
||||
UseFormRegister,
|
||||
} from 'react-hook-form';
|
||||
|
||||
interface RateLimitFieldProps {
|
||||
register: UseFormRegister<any>;
|
||||
errors: Merge<
|
||||
FieldError,
|
||||
FieldErrorsImpl<{
|
||||
limit: number;
|
||||
interval: number;
|
||||
intervalUnit: string;
|
||||
}>
|
||||
>;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function RateLimitField({
|
||||
register,
|
||||
disabled,
|
||||
id,
|
||||
errors,
|
||||
title,
|
||||
}: RateLimitFieldProps) {
|
||||
return (
|
||||
<Box className="px-4">
|
||||
{title ? <Text className="py-4 font-semibold">{title}</Text> : null}
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Text>Limit</Text>
|
||||
<Input
|
||||
{...register(`${id}.limit`)}
|
||||
disabled={disabled}
|
||||
id={`${id}.limit`}
|
||||
type="number"
|
||||
placeholder=""
|
||||
className="max-w-60"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.limit}
|
||||
helperText={errors?.limit?.message}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Text>Interval</Text>
|
||||
<Input
|
||||
{...register(`${id}.interval`)}
|
||||
disabled={disabled}
|
||||
id={`${id}.interval`}
|
||||
type="number"
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
className="max-w-32"
|
||||
error={!!errors?.interval}
|
||||
helperText={errors?.interval?.message}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ControlledSelect
|
||||
{...register(`${id}.intervalUnit`)}
|
||||
disabled={disabled}
|
||||
variant="normal"
|
||||
id={`${id}.intervalUnit`}
|
||||
className="w-27"
|
||||
defaultValue="m"
|
||||
hideEmptyHelperText
|
||||
>
|
||||
{intervalUnitOptions.map(({ value, label }) => (
|
||||
<Option key={`${id}.intervalUnit.${value}`} value={value}>
|
||||
{label}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledSelect>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RateLimitField } from './RateLimitField';
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { rateLimitingItemValidationSchema } from '@/features/projects/rate-limiting/settings/components/validationSchemas';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useUpdateRateLimitConfigMutation,
|
||||
type ConfigConfigUpdateInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { RateLimitField } from 'features/projects/rate-limiting/settings/components/RateLimitField';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
rateLimit: rateLimitingItemValidationSchema,
|
||||
});
|
||||
|
||||
export interface RateLimitDefaultValues {
|
||||
enabled: boolean;
|
||||
rateLimit: { limit: number; interval: number; intervalUnit: string };
|
||||
}
|
||||
|
||||
export interface RateLimitingFormProps {
|
||||
defaultValues: RateLimitDefaultValues;
|
||||
serviceName: keyof ConfigConfigUpdateInput;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export type RateLimitingFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function RateLimitingForm({
|
||||
defaultValues,
|
||||
serviceName,
|
||||
title,
|
||||
loading,
|
||||
}: RateLimitingFormProps) {
|
||||
const { openDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
const [updateRateLimitConfig] = useUpdateRateLimitConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const form = useForm<RateLimitingFormValues>({
|
||||
defaultValues: defaultValues.enabled
|
||||
? defaultValues
|
||||
: {
|
||||
enabled: false,
|
||||
rateLimit: {
|
||||
limit: 0,
|
||||
interval: 0,
|
||||
intervalUnit: 's',
|
||||
},
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && defaultValues.enabled) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [loading, defaultValues, form]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading rate limits..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
formState,
|
||||
watch,
|
||||
} = form;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const handleSubmit = async (formValues: RateLimitingFormValues) => {
|
||||
const updateConfigPromise = updateRateLimitConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
[serviceName]: {
|
||||
rateLimit: formValues.enabled
|
||||
? {
|
||||
limit: formValues.rateLimit.limit,
|
||||
interval: `${formValues.rateLimit.interval}${formValues.rateLimit.intervalUnit}`,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfigPromise;
|
||||
form.reset(formValues);
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: `Updating ${title} rate limit settings...`,
|
||||
successMessage: `${title} rate limit settings updated successfully`,
|
||||
errorMessage: `Failed to update ${title} rate limit settings`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<SettingsContainer
|
||||
title={title}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col px-0"
|
||||
>
|
||||
<Divider />
|
||||
<RateLimitField
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.rateLimit}
|
||||
id="rateLimit"
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RateLimitingForm } from './RateLimitingForm';
|
||||
@@ -0,0 +1,194 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { rateLimitingItemValidationSchema } from '@/features/projects/rate-limiting/settings/components/validationSchemas';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUpdateRunServiceConfigMutation } from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { RateLimitField } from 'features/projects/rate-limiting/settings/components/RateLimitField';
|
||||
import type { UseGetRunServiceRateLimitsReturn } from 'features/projects/rate-limiting/settings/hooks/useGetRunServiceRateLimits/useGetRunServiceRateLimits';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
ports: Yup.array().of(rateLimitingItemValidationSchema),
|
||||
});
|
||||
|
||||
export type RunServiceLimitingFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export interface RunServiceLimitingFormProps {
|
||||
title?: string;
|
||||
serviceId?: string;
|
||||
loading?: boolean;
|
||||
enabledDefault?: boolean;
|
||||
ports?: UseGetRunServiceRateLimitsReturn['services'][0]['ports'];
|
||||
}
|
||||
|
||||
export default function RunServiceLimitingForm({
|
||||
title,
|
||||
serviceId,
|
||||
ports,
|
||||
loading,
|
||||
enabledDefault,
|
||||
}: RunServiceLimitingFormProps) {
|
||||
const { openDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
const [updateRunServiceRateLimit] = useUpdateRunServiceConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const form = useForm<RunServiceLimitingFormValues>({
|
||||
defaultValues: {
|
||||
enabled: enabledDefault,
|
||||
ports: [
|
||||
...ports.map((port) => ({
|
||||
limit: port?.rateLimit?.limit,
|
||||
interval: port?.rateLimit?.interval,
|
||||
intervalUnit: port?.rateLimit?.intervalUnit,
|
||||
})),
|
||||
],
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && enabledDefault) {
|
||||
form.reset({
|
||||
enabled: enabledDefault,
|
||||
ports: [
|
||||
...ports.map((port) => ({
|
||||
limit: port?.rateLimit?.limit,
|
||||
interval: port?.rateLimit?.interval,
|
||||
intervalUnit: port?.rateLimit?.intervalUnit,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [loading, enabledDefault, ports, form]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading rate limits..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
formState,
|
||||
watch,
|
||||
} = form;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const handleSubmit = async (formValues: RunServiceLimitingFormValues) => {
|
||||
const updateConfigPromise = updateRunServiceRateLimit({
|
||||
variables: {
|
||||
appID: currentProject?.id,
|
||||
serviceID: serviceId,
|
||||
config: {
|
||||
ports: ports.map((port, index) => {
|
||||
const rateLimit = formValues.ports[index];
|
||||
return {
|
||||
...port,
|
||||
rateLimit: enabled
|
||||
? {
|
||||
limit: rateLimit.limit,
|
||||
interval: `${rateLimit.interval}${rateLimit.intervalUnit}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfigPromise;
|
||||
form.reset(formValues);
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating Run service rate limit settings...',
|
||||
successMessage: 'Run service rate limit settings updated successfully',
|
||||
errorMessage: 'Failed to update Run service rate limit settings',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<SettingsContainer
|
||||
title={title}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col px-0"
|
||||
>
|
||||
<Divider />
|
||||
{ports.map((port, index) => {
|
||||
if (port?.type !== 'http' || !port?.publish) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldTitle = `${port.type} <-> ${port.port}`.toUpperCase();
|
||||
const showDivider = index < ports.length - 1;
|
||||
return (
|
||||
<div key={`ports.${port.port}`}>
|
||||
<RateLimitField
|
||||
title={fieldTitle}
|
||||
disabled={!enabled}
|
||||
register={register}
|
||||
errors={errors.ports}
|
||||
id={`ports.${index}`}
|
||||
/>
|
||||
{showDivider && <Divider />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunServiceLimitingForm } from './RunServiceLimitingForm';
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const rateLimitingItemValidationSchema = Yup.object({
|
||||
limit: Yup.number()
|
||||
.required('Limit is required.')
|
||||
.min(1)
|
||||
.positive('Limit must be a positive number')
|
||||
.typeError('Limit must be a number.'),
|
||||
interval: Yup.number()
|
||||
.required('Interval is required.')
|
||||
.min(1)
|
||||
.positive('Interval must be a positive number')
|
||||
.typeError('Interval must be a number.'),
|
||||
intervalUnit: Yup.string()
|
||||
.required('Interval unit is required.')
|
||||
.oneOf(['s', 'm', 'h']),
|
||||
});
|
||||
|
||||
export const intervalUnitOptions = [
|
||||
{ value: 's', label: 'seconds' },
|
||||
{ value: 'm', label: 'minutes' },
|
||||
{ value: 'h', label: 'hours' },
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useGetRateLimits } from './useGetRateLimits';
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { useGetRateLimitConfigQuery } from '@/utils/__generated__/graphql';
|
||||
import { DEFAULT_RATE_LIMITS } from 'features/projects/rate-limiting/settings/utils/constants';
|
||||
import { parseIntervalNameUnit } from 'features/projects/rate-limiting/settings/utils/parseIntervalNameUnit';
|
||||
|
||||
export default function useGetRateLimits() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
const { data, loading } = useGetRateLimitConfigQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
resolve: true,
|
||||
},
|
||||
skip: !currentProject,
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const authRateLimit = data?.config?.auth?.rateLimit;
|
||||
const hasuraRateLimit = data?.config?.hasura?.rateLimit;
|
||||
const storageRateLimit = data?.config?.storage?.rateLimit;
|
||||
const functionsRateLimit = data?.config?.functions?.rateLimit;
|
||||
|
||||
const { bruteForce, emails, global, signups, sms } = authRateLimit || {};
|
||||
const { limit: bruteForceLimit, interval: bruteForceIntervalStr } =
|
||||
bruteForce || {};
|
||||
const { interval: bruteForceInterval, intervalUnit: bruteForceIntervalUnit } =
|
||||
parseIntervalNameUnit(bruteForceIntervalStr);
|
||||
|
||||
const { limit: emailsLimit, interval: emailsIntervalStr } = emails || {};
|
||||
const { interval: emailsInterval, intervalUnit: emailsIntervalUnit } =
|
||||
parseIntervalNameUnit(emailsIntervalStr);
|
||||
|
||||
const { limit: globalLimit, interval: globalIntervalStr } = global || {};
|
||||
const { interval: globalInterval, intervalUnit: globalIntervalUnit } =
|
||||
parseIntervalNameUnit(globalIntervalStr);
|
||||
|
||||
const { limit: signupsLimit, interval: signupsIntervalStr } = signups || {};
|
||||
const { interval: signupsInterval, intervalUnit: signupsIntervalUnit } =
|
||||
parseIntervalNameUnit(signupsIntervalStr);
|
||||
|
||||
const { limit: smsLimit, interval: smsIntervalStr } = sms || {};
|
||||
const { interval: smsInterval, intervalUnit: smsIntervalUnit } =
|
||||
parseIntervalNameUnit(smsIntervalStr);
|
||||
|
||||
const { limit: hasuraLimit, interval: hasuraIntervalStr } =
|
||||
hasuraRateLimit || {};
|
||||
const { interval: hasuraInterval, intervalUnit: hasuraIntervalUnit } =
|
||||
parseIntervalNameUnit(hasuraIntervalStr);
|
||||
|
||||
const { limit: storageLimit, interval: storageIntervalStr } =
|
||||
storageRateLimit || {};
|
||||
const { interval: storageInterval, intervalUnit: storageIntervalUnit } =
|
||||
parseIntervalNameUnit(storageIntervalStr);
|
||||
|
||||
const { limit: functionsLimit, interval: functionsIntervalStr } =
|
||||
functionsRateLimit || {};
|
||||
const { interval: functionsInterval, intervalUnit: functionsIntervalUnit } =
|
||||
parseIntervalNameUnit(functionsIntervalStr);
|
||||
|
||||
return {
|
||||
authRateLimit: {
|
||||
enabled: !!authRateLimit,
|
||||
bruteForce: {
|
||||
limit: bruteForceLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: bruteForceInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit:
|
||||
bruteForceIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
emails: {
|
||||
limit: emailsLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: emailsInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: emailsIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
global: {
|
||||
limit: globalLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: globalInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: globalIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
signups: {
|
||||
limit: signupsLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: signupsInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: signupsIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
sms: {
|
||||
limit: smsLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: smsInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: smsIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
},
|
||||
hasuraDefaultValues: {
|
||||
enabled: !!hasuraRateLimit,
|
||||
rateLimit: {
|
||||
limit: hasuraLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: hasuraInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: hasuraIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
},
|
||||
storageDefaultValues: {
|
||||
enabled: !!storageRateLimit,
|
||||
rateLimit: {
|
||||
limit: storageLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: storageInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: storageIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
},
|
||||
functionsDefaultValues: {
|
||||
enabled: !!functionsRateLimit,
|
||||
rateLimit: {
|
||||
limit: functionsLimit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: functionsInterval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: functionsIntervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
},
|
||||
},
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useGetRunServiceRateLimits } from './useGetRunServiceRateLimits';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import {
|
||||
useGetLocalRunServiceRateLimitQuery,
|
||||
useGetRunServicesRateLimitQuery,
|
||||
type GetRunServicesRateLimitQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { DEFAULT_RATE_LIMITS } from 'features/projects/rate-limiting/settings/utils/constants';
|
||||
import { parseIntervalNameUnit } from 'features/projects/rate-limiting/settings/utils/parseIntervalNameUnit';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type RunService = Pick<
|
||||
GetRunServicesRateLimitQuery['app']['runServices'][0],
|
||||
'config'
|
||||
> & {
|
||||
id?: string;
|
||||
serviceID?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
subdomain?: string;
|
||||
};
|
||||
|
||||
export interface UseGetRunServiceRateLimitsReturn {
|
||||
services: {
|
||||
name?: string;
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
ports?: {
|
||||
type?: string;
|
||||
port?: string;
|
||||
publish?: boolean;
|
||||
rateLimit?: {
|
||||
limit?: number;
|
||||
interval?: number;
|
||||
intervalUnit?: string;
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function useGetRunServiceRateLimits(): UseGetRunServiceRateLimitsReturn {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { data, loading: loadingPlatformServices } =
|
||||
useGetRunServicesRateLimitQuery({
|
||||
variables: {
|
||||
appID: currentProject?.id,
|
||||
resolve: false,
|
||||
},
|
||||
skip: !isPlatform,
|
||||
});
|
||||
|
||||
const { loading: loadingLocalServices, data: localServicesData } =
|
||||
useGetLocalRunServiceRateLimitQuery({
|
||||
variables: { appID: currentProject?.id, resolve: false },
|
||||
skip: isPlatform,
|
||||
client: localMimirClient,
|
||||
});
|
||||
|
||||
const platformServices = useMemo(
|
||||
() => data?.app?.runServices.map((service) => service) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const localServices = useMemo(
|
||||
() => localServicesData?.runServiceConfigs.map((service) => service) ?? [],
|
||||
[localServicesData],
|
||||
);
|
||||
|
||||
const services: RunService[] = isPlatform ? platformServices : localServices;
|
||||
const loading = isPlatform ? loadingPlatformServices : loadingLocalServices;
|
||||
|
||||
const servicesInfo = services.map((service) => {
|
||||
const enabled = service?.config?.ports?.some(
|
||||
(port) => port?.rateLimit && port?.type === 'http' && port?.publish,
|
||||
);
|
||||
|
||||
const ports = service?.config?.ports?.map((port) => {
|
||||
const { interval, intervalUnit } = parseIntervalNameUnit(
|
||||
port?.rateLimit?.interval,
|
||||
);
|
||||
const rateLimit = {
|
||||
limit: port?.rateLimit?.limit || DEFAULT_RATE_LIMITS.limit,
|
||||
interval: interval || DEFAULT_RATE_LIMITS.interval,
|
||||
intervalUnit: intervalUnit || DEFAULT_RATE_LIMITS.intervalUnit,
|
||||
};
|
||||
return {
|
||||
type: port?.type,
|
||||
publish: port?.publish,
|
||||
port: port?.port,
|
||||
rateLimit,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
enabled,
|
||||
name: service.config?.name,
|
||||
id: service.id ?? service.serviceID,
|
||||
ports,
|
||||
};
|
||||
});
|
||||
|
||||
return { services: servicesInfo, loading };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DEFAULT_RATE_LIMITS = {
|
||||
limit: 1000,
|
||||
interval: 5,
|
||||
intervalUnit: 'm',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './constants';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as parseIntervalNameUnit } from './parseIntervalNameUnit';
|
||||
@@ -0,0 +1,18 @@
|
||||
export default function parseIntervalNameUnit(interval: string) {
|
||||
if (!interval) {
|
||||
return {};
|
||||
}
|
||||
const regex = /^(\d+)([a-zA-Z])$/;
|
||||
const match = interval.match(regex);
|
||||
|
||||
if (!match) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [, intervalValue, intervalUnit] = match;
|
||||
|
||||
return {
|
||||
interval: parseInt(intervalValue, 10),
|
||||
intervalUnit,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
@@ -99,38 +100,50 @@ export default function ServiceForm({
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const getFormattedConfig = (values: ServiceFormValues) => {
|
||||
// Remove any __typename property from the values
|
||||
const sanitizedValues = removeTypename(values) as ServiceFormValues;
|
||||
const sanitizedInitialDataPorts = initialData?.ports
|
||||
? removeTypename(initialData.ports)
|
||||
: [];
|
||||
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: values.name,
|
||||
name: sanitizedValues.name,
|
||||
image: {
|
||||
image: values.image,
|
||||
image: sanitizedValues.image,
|
||||
},
|
||||
command: parse(values.command).map((item) => item.toString()),
|
||||
command: parse(sanitizedValues.command).map((item) => item.toString()),
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: values.compute.cpu,
|
||||
memory: values.compute.memory,
|
||||
cpu: sanitizedValues.compute.cpu,
|
||||
memory: sanitizedValues.compute.memory,
|
||||
},
|
||||
storage: values.storage.map((item) => ({
|
||||
storage: sanitizedValues.storage.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: values.replicas,
|
||||
replicas: sanitizedValues.replicas,
|
||||
},
|
||||
environment: values.environment.map((item) => ({
|
||||
environment: sanitizedValues.environment.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
ports: values.ports.map((item) => ({
|
||||
ports: sanitizedValues.ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
ingresses: item.ingresses,
|
||||
rateLimit:
|
||||
sanitizedInitialDataPorts.find(
|
||||
(port) => port.port === item.port && port.type === item.type,
|
||||
)?.rateLimit ?? null,
|
||||
})),
|
||||
healthCheck: values.healthCheck
|
||||
healthCheck: sanitizedValues.healthCheck
|
||||
? {
|
||||
port: values.healthCheck?.port,
|
||||
initialDelaySeconds: values.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: values.healthCheck?.probePeriodSeconds,
|
||||
port: sanitizedValues.healthCheck?.port,
|
||||
initialDelaySeconds:
|
||||
sanitizedValues.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: sanitizedValues.healthCheck?.probePeriodSeconds,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@@ -303,7 +316,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -343,7 +356,7 @@ export default function ServiceForm({
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -374,7 +387,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="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -422,7 +435,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
@@ -30,6 +30,13 @@ export const validationSchema = Yup.object({
|
||||
port: Yup.number().required(),
|
||||
type: Yup.mixed<PortTypes>().oneOf(Object.values(PortTypes)).required(),
|
||||
publish: Yup.boolean().default(false),
|
||||
ingresses: Yup.array()
|
||||
.of(
|
||||
Yup.object().shape({
|
||||
fqdn: Yup.array().of(Yup.string()),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
storage: Yup.array().of(
|
||||
@@ -62,7 +69,16 @@ export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: ServiceFormValues & { subdomain?: string }; // subdomain is only set on the backend
|
||||
initialData?: Omit<ServiceFormValues, 'ports'> & {
|
||||
subdomain?: string;
|
||||
ports: {
|
||||
port: number;
|
||||
type: PortTypes;
|
||||
publish: boolean;
|
||||
ingresses?: { fqdn?: string[] }[] | null;
|
||||
rateLimit?: { limit: number; interval: string } | null;
|
||||
}[];
|
||||
}; // subdomain is only set on the backend
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function ComputeFormSection({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
href="https://docs.nhost.io/guides/run/resources"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import { PortTypes } from '@/features/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import { type ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { getRunServicePortURL } from '@/utils/helpers';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function PortsFormSection() {
|
||||
@@ -40,14 +41,8 @@ export default function PortsFormSection() {
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, subdomain: string) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.name}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
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 justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
@@ -69,14 +64,14 @@ export default function PortsFormSection() {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ port: null, type: null, publish: false })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -133,16 +128,18 @@ export default function PortsFormSection() {
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showURL(index) && (
|
||||
<InfoCard
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.subdomain,
|
||||
value={getRunServicePortURL(
|
||||
formValues?.subdomain,
|
||||
currentProject?.region.name,
|
||||
currentProject?.region.domain,
|
||||
formValues.ports[index],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function ReplicasFormSection() {
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
href="https://docs.nhost.io/guides/run/resources"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ServiceConfirmationDialogProps {
|
||||
/**
|
||||
@@ -28,10 +29,21 @@ export default function ServiceConfirmationDialog({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ServiceConfirmationDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const approximatePriceForService = parseFloat(
|
||||
(formValues.compute.cpu * formValues.replicas * COST_PER_VCPU).toFixed(2),
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
<Box className="grid grid-flow-row gap-4">
|
||||
@@ -74,7 +86,12 @@ export default function ServiceConfirmationDialog({
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button color="primary" onClick={onSubmit} autoFocus>
|
||||
<Button
|
||||
loading={isSubmitting}
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
autoFocus
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import { getRunServicePortURL } from '@/utils/helpers';
|
||||
import type { ConfigRunServicePort } from '@/utils/__generated__/graphql';
|
||||
|
||||
export interface ServiceDetailsDialogProps {
|
||||
@@ -32,11 +33,7 @@ export default function ServiceDetailsDialog({
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const getPortURL = (_port: string | number) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.name}.${currentProject?.region.domain}`;
|
||||
};
|
||||
const publishedPorts = ports.filter((port) => port.publish);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-6 pb-6">
|
||||
@@ -48,18 +45,21 @@ export default function ServiceDetailsDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ports?.length > 0 && (
|
||||
{publishedPorts?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Ports</Text>
|
||||
{ports
|
||||
.filter((port) => port.publish)
|
||||
.map((port) => (
|
||||
<InfoCard
|
||||
key={String(port.port)}
|
||||
title={`${port.type} <--> ${port.port}`}
|
||||
value={getPortURL(port.port)}
|
||||
/>
|
||||
))}
|
||||
{publishedPorts.map((port) => (
|
||||
<InfoCard
|
||||
key={String(port.port)}
|
||||
title={`${port.type} <--> ${port.port}`}
|
||||
value={getRunServicePortURL(
|
||||
subdomain,
|
||||
currentProject?.region.name,
|
||||
currentProject?.region.domain,
|
||||
port,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function ServicesList({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
ingresses: item.ingresses,
|
||||
rateLimit: item.rateLimit,
|
||||
})),
|
||||
compute: service.config?.resources?.compute ?? {
|
||||
cpu: 62,
|
||||
@@ -178,7 +180,10 @@ export default function ServicesList({
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteService(service)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteService(service);
|
||||
}}
|
||||
disabled={!isPlatform}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
query getRateLimitConfig($appId: uuid!, $resolve: Boolean!) {
|
||||
config(appID: $appId, resolve: $resolve) {
|
||||
hasura {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
storage {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
functions {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
auth {
|
||||
rateLimit {
|
||||
bruteForce {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
emails {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
global {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
signups {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
sms {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
mutation UpdateRateLimitConfig(
|
||||
$appId: uuid!
|
||||
$config: ConfigConfigUpdateInput!
|
||||
) {
|
||||
updateConfig(appID: $appId, config: $config) {
|
||||
hasura {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
storage {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
functions {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
auth {
|
||||
rateLimit {
|
||||
bruteForce {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
emails {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
global {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
signups {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
sms {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation UpdateDatabaseVersion($appId: uuid!, $version: String!) {
|
||||
changeDatabaseVersion(appID: $appId, version: $version)
|
||||
}
|
||||
11
dashboard/src/gql/logs/getSystemLogs.gql
Normal file
11
dashboard/src/gql/logs/getSystemLogs.gql
Normal file
@@ -0,0 +1,11 @@
|
||||
query getSystemLogs(
|
||||
$appID: String!
|
||||
$action: String!
|
||||
$from: Timestamp
|
||||
$to: Timestamp
|
||||
) {
|
||||
systemLogs(appID: $appID, action: $action, from: $from) {
|
||||
timestamp
|
||||
log
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ fragment RunServiceConfig on ConfigRunServiceConfig {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
healthCheck {
|
||||
port
|
||||
|
||||
38
dashboard/src/gql/services/getRunServicesRateLimit.gql
Normal file
38
dashboard/src/gql/services/getRunServicesRateLimit.gql
Normal file
@@ -0,0 +1,38 @@
|
||||
fragment RunServiceRateLimit on ConfigRunServiceConfig {
|
||||
name
|
||||
ports {
|
||||
port
|
||||
type
|
||||
publish
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query getRunServicesRateLimit($appID: uuid!, $resolve: Boolean!) {
|
||||
app(id: $appID) {
|
||||
runServices {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
subdomain
|
||||
config(resolve: $resolve) {
|
||||
...RunServiceRateLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query getLocalRunServiceRateLimit($appID: uuid!, $resolve: Boolean!) {
|
||||
runServiceConfigs(appID: $appID, resolve: $resolve) {
|
||||
serviceID
|
||||
config {
|
||||
...RunServiceRateLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export default function AppIndexPage() {
|
||||
return <ApplicationUnpausing />;
|
||||
case ApplicationStatus.Restoring:
|
||||
return <ApplicationRestoring />;
|
||||
case ApplicationStatus.Migrating:
|
||||
return <ApplicationLive />;
|
||||
default:
|
||||
return <ApplicationUnknown />;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ServicesPage() {
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="w-5 h-5" />
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Create a new run service</Text>
|
||||
</Box>
|
||||
),
|
||||
@@ -104,7 +104,7 @@ export default function ServicesPage() {
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="w-5 h-5" />
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Create a new service</Text>
|
||||
</Box>
|
||||
),
|
||||
@@ -125,23 +125,23 @@ export default function ServicesPage() {
|
||||
|
||||
if (services.length === 0 && !loading) {
|
||||
return (
|
||||
<Container className="mx-auto space-y-5 overflow-x-hidden max-w-9xl">
|
||||
<Container className="mx-auto max-w-9xl space-y-5 overflow-x-hidden">
|
||||
<div className="flex flex-row place-content-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
disabled={!isPlatform}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 rounded-lg border px-48 py-12 shadow-sm">
|
||||
<ServicesIcon className="h-10 w-10" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="font-medium text-center" variant="h3">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No custom services are available
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
@@ -149,13 +149,13 @@ export default function ServicesPage() {
|
||||
</Text>
|
||||
</div>
|
||||
{isPlatform ? (
|
||||
<div className="flex flex-row rounded-lg place-content-between ">
|
||||
<div className="flex flex-row place-content-between rounded-lg ">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
@@ -168,12 +168,12 @@ export default function ServicesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Box className="flex flex-row p-4 place-content-end border-b-1">
|
||||
<Box className="flex flex-row place-content-end border-b-1 p-4">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
disabled={!isPlatform}
|
||||
>
|
||||
Add service
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AllowedRedirectURLsSettings } from '@/features/authentication/settings/
|
||||
import { AuthServiceVersionSettings } from '@/features/authentication/settings/components/AuthServiceVersionSettings';
|
||||
import { BlockedEmailSettings } from '@/features/authentication/settings/components/BlockedEmailSettings';
|
||||
import { ClientURLSettings } from '@/features/authentication/settings/components/ClientURLSettings';
|
||||
import { ConcealErrorsSettings } from '@/features/authentication/settings/components/ConcealErrorsSettings';
|
||||
import { DisableNewUsersSettings } from '@/features/authentication/settings/components/DisableNewUsersSettings';
|
||||
import { GravatarSettings } from '@/features/authentication/settings/components/GravatarSettings';
|
||||
import { MFASettings } from '@/features/authentication/settings/components/MFASettings';
|
||||
@@ -43,7 +44,7 @@ export default function SettingsAuthenticationPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row bg-transparent gap-y-6"
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<AuthServiceVersionSettings />
|
||||
@@ -55,6 +56,7 @@ export default function SettingsAuthenticationPage() {
|
||||
<SessionSettings />
|
||||
<GravatarSettings />
|
||||
<DisableNewUsersSettings />
|
||||
<ConcealErrorsSettings />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { SettingsLayout } from '@/components/layout/SettingsLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AuthLimitingForm } from '@/features/projects/rate-limiting/settings/components/AuthLimitingForm';
|
||||
import { RateLimitingForm } from '@/features/projects/rate-limiting/settings/components/RateLimitingForm';
|
||||
import { RunServiceLimitingForm } from '@/features/projects/rate-limiting/settings/components/RunServiceLimitingForm';
|
||||
import { useGetRateLimits } from '@/features/projects/rate-limiting/settings/hooks/useGetRateLimits';
|
||||
import { useGetRunServiceRateLimits } from '@/features/projects/rate-limiting/settings/hooks/useGetRunServiceRateLimits';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
export default function RateLimiting() {
|
||||
const { services, loading } = useGetRunServiceRateLimits();
|
||||
|
||||
const {
|
||||
hasuraDefaultValues,
|
||||
functionsDefaultValues,
|
||||
storageDefaultValues,
|
||||
loading: loadingBaseServices,
|
||||
} = useGetRateLimits();
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<Box className="flex flex-row items-center gap-4 overflow-hidden rounded-lg border-1 p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Text className="text-lg font-semibold">Rate Limiting</Text>
|
||||
|
||||
<Text color="secondary">
|
||||
Learn more about
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/rate-limits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="ml-1 font-medium"
|
||||
>
|
||||
Rate Limiting
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
<AuthLimitingForm />
|
||||
<RateLimitingForm
|
||||
defaultValues={hasuraDefaultValues}
|
||||
loading={loadingBaseServices}
|
||||
serviceName="hasura"
|
||||
title="Hasura"
|
||||
/>
|
||||
<RateLimitingForm
|
||||
defaultValues={storageDefaultValues}
|
||||
loading={loadingBaseServices}
|
||||
serviceName="storage"
|
||||
title="Storage"
|
||||
/>
|
||||
<RateLimitingForm
|
||||
defaultValues={functionsDefaultValues}
|
||||
loading={loadingBaseServices}
|
||||
serviceName="functions"
|
||||
title="Functions"
|
||||
/>
|
||||
{services?.map((service) => {
|
||||
if (
|
||||
service?.ports?.some((port) => port?.type === 'http' && port?.publish)
|
||||
) {
|
||||
return (
|
||||
<RunServiceLimitingForm
|
||||
enabledDefault={service.enabled}
|
||||
key={service.id}
|
||||
title={service.name}
|
||||
serviceId={service.id}
|
||||
ports={service.ports}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
RateLimiting.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { styled } from '@mui/material';
|
||||
import { useSignUpEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -39,6 +40,9 @@ export default function SignUpPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// x-cf-turnstile-response
|
||||
const [turnstileResponse, setTurnstileResponse] = useState(null);
|
||||
|
||||
const form = useForm<SignUpFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
@@ -66,11 +70,27 @@ export default function SignUpPage() {
|
||||
password,
|
||||
displayName,
|
||||
}: SignUpFormValues) {
|
||||
if (!turnstileResponse) {
|
||||
toast.error(
|
||||
'Please complete the signup verification challenge to continue.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { needsEmailVerification } = await signUpEmailPassword(
|
||||
email,
|
||||
password,
|
||||
{ displayName },
|
||||
{
|
||||
displayName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-cf-turnstile-response': turnstileResponse,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
@@ -94,7 +114,7 @@ export default function SignUpPage() {
|
||||
Sign Up
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<Box className="grid grid-flow-row gap-4 p-6 bg-transparent border rounded-md lg:p-12">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="!bg-white !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60"
|
||||
@@ -122,7 +142,7 @@ export default function SignUpPage() {
|
||||
|
||||
<div className="relative py-2">
|
||||
<Text
|
||||
className="absolute left-0 right-0 top-1/2 mx-auto w-12 -translate-y-1/2 bg-black px-2 text-center text-sm"
|
||||
className="absolute left-0 right-0 w-12 px-2 mx-auto text-sm text-center -translate-y-1/2 bg-black top-1/2"
|
||||
color="disabled"
|
||||
>
|
||||
OR
|
||||
@@ -172,6 +192,12 @@ export default function SignUpPage() {
|
||||
helperText={formState.errors.password?.message}
|
||||
/>
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||
options={{ theme: 'dark', size: 'flexible' }}
|
||||
onSuccess={setTurnstileResponse}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
@@ -188,7 +214,7 @@ export default function SignUpPage() {
|
||||
|
||||
<Divider className="!my-2" />
|
||||
|
||||
<Text color="secondary" className="text-center text-sm">
|
||||
<Text color="secondary" className="text-sm text-center">
|
||||
By signing up, you agree to our{' '}
|
||||
<NavLink
|
||||
href="https://nhost.io/legal/terms-of-service"
|
||||
@@ -212,7 +238,7 @@ export default function SignUpPage() {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
<Text color="secondary" className="text-base text-center lg:text-lg">
|
||||
Already have an account?{' '}
|
||||
<NavLink href="/signin" color="white" className="font-medium">
|
||||
Sign In
|
||||
|
||||
511
dashboard/src/utils/__generated__/graphql.ts
generated
511
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -199,6 +199,8 @@ export type ConfigAuth = {
|
||||
__typename?: 'ConfigAuth';
|
||||
elevatedPrivileges?: Maybe<ConfigAuthElevatedPrivileges>;
|
||||
method?: Maybe<ConfigAuthMethod>;
|
||||
misc?: Maybe<ConfigAuthMisc>;
|
||||
rateLimit?: Maybe<ConfigAuthRateLimit>;
|
||||
redirections?: Maybe<ConfigAuthRedirections>;
|
||||
/** Resources for the service */
|
||||
resources?: Maybe<ConfigResources>;
|
||||
@@ -223,6 +225,8 @@ export type ConfigAuthComparisonExp = {
|
||||
_or?: InputMaybe<Array<ConfigAuthComparisonExp>>;
|
||||
elevatedPrivileges?: InputMaybe<ConfigAuthElevatedPrivilegesComparisonExp>;
|
||||
method?: InputMaybe<ConfigAuthMethodComparisonExp>;
|
||||
misc?: InputMaybe<ConfigAuthMiscComparisonExp>;
|
||||
rateLimit?: InputMaybe<ConfigAuthRateLimitComparisonExp>;
|
||||
redirections?: InputMaybe<ConfigAuthRedirectionsComparisonExp>;
|
||||
resources?: InputMaybe<ConfigResourcesComparisonExp>;
|
||||
session?: InputMaybe<ConfigAuthSessionComparisonExp>;
|
||||
@@ -255,6 +259,8 @@ export type ConfigAuthElevatedPrivilegesUpdateInput = {
|
||||
export type ConfigAuthInsertInput = {
|
||||
elevatedPrivileges?: InputMaybe<ConfigAuthElevatedPrivilegesInsertInput>;
|
||||
method?: InputMaybe<ConfigAuthMethodInsertInput>;
|
||||
misc?: InputMaybe<ConfigAuthMiscInsertInput>;
|
||||
rateLimit?: InputMaybe<ConfigAuthRateLimitInsertInput>;
|
||||
redirections?: InputMaybe<ConfigAuthRedirectionsInsertInput>;
|
||||
resources?: InputMaybe<ConfigResourcesInsertInput>;
|
||||
session?: InputMaybe<ConfigAuthSessionInsertInput>;
|
||||
@@ -684,6 +690,62 @@ export type ConfigAuthMethodWebauthnUpdateInput = {
|
||||
relyingParty?: InputMaybe<ConfigAuthMethodWebauthnRelyingPartyUpdateInput>;
|
||||
};
|
||||
|
||||
export type ConfigAuthMisc = {
|
||||
__typename?: 'ConfigAuthMisc';
|
||||
concealErrors?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ConfigAuthMiscComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigAuthMiscComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigAuthMiscComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigAuthMiscComparisonExp>>;
|
||||
concealErrors?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigAuthMiscInsertInput = {
|
||||
concealErrors?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ConfigAuthMiscUpdateInput = {
|
||||
concealErrors?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ConfigAuthRateLimit = {
|
||||
__typename?: 'ConfigAuthRateLimit';
|
||||
bruteForce?: Maybe<ConfigRateLimit>;
|
||||
emails?: Maybe<ConfigRateLimit>;
|
||||
global?: Maybe<ConfigRateLimit>;
|
||||
signups?: Maybe<ConfigRateLimit>;
|
||||
sms?: Maybe<ConfigRateLimit>;
|
||||
};
|
||||
|
||||
export type ConfigAuthRateLimitComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigAuthRateLimitComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigAuthRateLimitComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigAuthRateLimitComparisonExp>>;
|
||||
bruteForce?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
emails?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
global?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
signups?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
sms?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigAuthRateLimitInsertInput = {
|
||||
bruteForce?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
emails?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
global?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
signups?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
sms?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
};
|
||||
|
||||
export type ConfigAuthRateLimitUpdateInput = {
|
||||
bruteForce?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
emails?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
global?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
signups?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
sms?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
};
|
||||
|
||||
export type ConfigAuthRedirections = {
|
||||
__typename?: 'ConfigAuthRedirections';
|
||||
/** AUTH_ACCESS_CONTROL_ALLOWED_REDIRECT_URLS */
|
||||
@@ -834,6 +896,8 @@ export type ConfigAuthTotpUpdateInput = {
|
||||
export type ConfigAuthUpdateInput = {
|
||||
elevatedPrivileges?: InputMaybe<ConfigAuthElevatedPrivilegesUpdateInput>;
|
||||
method?: InputMaybe<ConfigAuthMethodUpdateInput>;
|
||||
misc?: InputMaybe<ConfigAuthMiscUpdateInput>;
|
||||
rateLimit?: InputMaybe<ConfigAuthRateLimitUpdateInput>;
|
||||
redirections?: InputMaybe<ConfigAuthRedirectionsUpdateInput>;
|
||||
resources?: InputMaybe<ConfigResourcesUpdateInput>;
|
||||
session?: InputMaybe<ConfigAuthSessionUpdateInput>;
|
||||
@@ -1233,6 +1297,7 @@ export type ConfigFloatComparisonExp = {
|
||||
export type ConfigFunctions = {
|
||||
__typename?: 'ConfigFunctions';
|
||||
node?: Maybe<ConfigFunctionsNode>;
|
||||
rateLimit?: Maybe<ConfigRateLimit>;
|
||||
resources?: Maybe<ConfigFunctionsResources>;
|
||||
};
|
||||
|
||||
@@ -1241,11 +1306,13 @@ export type ConfigFunctionsComparisonExp = {
|
||||
_not?: InputMaybe<ConfigFunctionsComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigFunctionsComparisonExp>>;
|
||||
node?: InputMaybe<ConfigFunctionsNodeComparisonExp>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
resources?: InputMaybe<ConfigFunctionsResourcesComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigFunctionsInsertInput = {
|
||||
node?: InputMaybe<ConfigFunctionsNodeInsertInput>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
resources?: InputMaybe<ConfigFunctionsResourcesInsertInput>;
|
||||
};
|
||||
|
||||
@@ -1291,6 +1358,7 @@ export type ConfigFunctionsResourcesUpdateInput = {
|
||||
|
||||
export type ConfigFunctionsUpdateInput = {
|
||||
node?: InputMaybe<ConfigFunctionsNodeUpdateInput>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
resources?: InputMaybe<ConfigFunctionsResourcesUpdateInput>;
|
||||
};
|
||||
|
||||
@@ -1415,6 +1483,7 @@ export type ConfigHasura = {
|
||||
/** JWT Secrets configuration */
|
||||
jwtSecrets?: Maybe<Array<ConfigJwtSecret>>;
|
||||
logs?: Maybe<ConfigHasuraLogs>;
|
||||
rateLimit?: Maybe<ConfigRateLimit>;
|
||||
/** Resources for the service */
|
||||
resources?: Maybe<ConfigResources>;
|
||||
/**
|
||||
@@ -1477,6 +1546,7 @@ export type ConfigHasuraComparisonExp = {
|
||||
events?: InputMaybe<ConfigHasuraEventsComparisonExp>;
|
||||
jwtSecrets?: InputMaybe<ConfigJwtSecretComparisonExp>;
|
||||
logs?: InputMaybe<ConfigHasuraLogsComparisonExp>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
resources?: InputMaybe<ConfigResourcesComparisonExp>;
|
||||
settings?: InputMaybe<ConfigHasuraSettingsComparisonExp>;
|
||||
version?: InputMaybe<ConfigStringComparisonExp>;
|
||||
@@ -1510,6 +1580,7 @@ export type ConfigHasuraInsertInput = {
|
||||
events?: InputMaybe<ConfigHasuraEventsInsertInput>;
|
||||
jwtSecrets?: InputMaybe<Array<ConfigJwtSecretInsertInput>>;
|
||||
logs?: InputMaybe<ConfigHasuraLogsInsertInput>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
resources?: InputMaybe<ConfigResourcesInsertInput>;
|
||||
settings?: InputMaybe<ConfigHasuraSettingsInsertInput>;
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
@@ -1602,6 +1673,7 @@ export type ConfigHasuraUpdateInput = {
|
||||
events?: InputMaybe<ConfigHasuraEventsUpdateInput>;
|
||||
jwtSecrets?: InputMaybe<Array<ConfigJwtSecretUpdateInput>>;
|
||||
logs?: InputMaybe<ConfigHasuraLogsUpdateInput>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
resources?: InputMaybe<ConfigResourcesUpdateInput>;
|
||||
settings?: InputMaybe<ConfigHasuraSettingsUpdateInput>;
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
@@ -2013,6 +2085,30 @@ export type ConfigProviderUpdateInput = {
|
||||
smtp?: InputMaybe<ConfigSmtpUpdateInput>;
|
||||
};
|
||||
|
||||
export type ConfigRateLimit = {
|
||||
__typename?: 'ConfigRateLimit';
|
||||
interval: Scalars['String'];
|
||||
limit: Scalars['ConfigUint32'];
|
||||
};
|
||||
|
||||
export type ConfigRateLimitComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigRateLimitComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigRateLimitComparisonExp>>;
|
||||
interval?: InputMaybe<ConfigStringComparisonExp>;
|
||||
limit?: InputMaybe<ConfigUint32ComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigRateLimitInsertInput = {
|
||||
interval: Scalars['String'];
|
||||
limit: Scalars['ConfigUint32'];
|
||||
};
|
||||
|
||||
export type ConfigRateLimitUpdateInput = {
|
||||
interval?: InputMaybe<Scalars['String']>;
|
||||
limit?: InputMaybe<Scalars['ConfigUint32']>;
|
||||
};
|
||||
|
||||
/** Resource configuration for a service */
|
||||
export type ConfigResources = {
|
||||
__typename?: 'ConfigResources';
|
||||
@@ -2126,6 +2222,8 @@ export type ConfigRunServiceConfigWithId = {
|
||||
export type ConfigRunServiceImage = {
|
||||
__typename?: 'ConfigRunServiceImage';
|
||||
image: Scalars['String'];
|
||||
/** content of "auths", i.e., { "auths": $THIS } */
|
||||
pullCredentials?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigRunServiceImageComparisonExp = {
|
||||
@@ -2133,14 +2231,17 @@ export type ConfigRunServiceImageComparisonExp = {
|
||||
_not?: InputMaybe<ConfigRunServiceImageComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigRunServiceImageComparisonExp>>;
|
||||
image?: InputMaybe<ConfigStringComparisonExp>;
|
||||
pullCredentials?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigRunServiceImageInsertInput = {
|
||||
image: Scalars['String'];
|
||||
pullCredentials?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigRunServiceImageUpdateInput = {
|
||||
image?: InputMaybe<Scalars['String']>;
|
||||
pullCredentials?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigRunServiceNameComparisonExp = {
|
||||
@@ -2155,6 +2256,7 @@ export type ConfigRunServicePort = {
|
||||
ingresses?: Maybe<Array<ConfigIngress>>;
|
||||
port: Scalars['ConfigPort'];
|
||||
publish?: Maybe<Scalars['Boolean']>;
|
||||
rateLimit?: Maybe<ConfigRateLimit>;
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
@@ -2165,6 +2267,7 @@ export type ConfigRunServicePortComparisonExp = {
|
||||
ingresses?: InputMaybe<ConfigIngressComparisonExp>;
|
||||
port?: InputMaybe<ConfigPortComparisonExp>;
|
||||
publish?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
type?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
@@ -2172,6 +2275,7 @@ export type ConfigRunServicePortInsertInput = {
|
||||
ingresses?: InputMaybe<Array<ConfigIngressInsertInput>>;
|
||||
port: Scalars['ConfigPort'];
|
||||
publish?: InputMaybe<Scalars['Boolean']>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
type: Scalars['String'];
|
||||
};
|
||||
|
||||
@@ -2179,6 +2283,7 @@ export type ConfigRunServicePortUpdateInput = {
|
||||
ingresses?: InputMaybe<Array<ConfigIngressUpdateInput>>;
|
||||
port?: InputMaybe<Scalars['ConfigPort']>;
|
||||
publish?: InputMaybe<Scalars['Boolean']>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
type?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
@@ -2386,6 +2491,7 @@ export type ConfigStandardOauthProviderWithScopeUpdateInput = {
|
||||
export type ConfigStorage = {
|
||||
__typename?: 'ConfigStorage';
|
||||
antivirus?: Maybe<ConfigStorageAntivirus>;
|
||||
rateLimit?: Maybe<ConfigRateLimit>;
|
||||
/**
|
||||
* Networking (custom domains at the moment) are not allowed as we need to do further
|
||||
* configurations in the CDN. We will enable it again in the future.
|
||||
@@ -2427,18 +2533,21 @@ export type ConfigStorageComparisonExp = {
|
||||
_not?: InputMaybe<ConfigStorageComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigStorageComparisonExp>>;
|
||||
antivirus?: InputMaybe<ConfigStorageAntivirusComparisonExp>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitComparisonExp>;
|
||||
resources?: InputMaybe<ConfigResourcesComparisonExp>;
|
||||
version?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigStorageInsertInput = {
|
||||
antivirus?: InputMaybe<ConfigStorageAntivirusInsertInput>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitInsertInput>;
|
||||
resources?: InputMaybe<ConfigResourcesInsertInput>;
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigStorageUpdateInput = {
|
||||
antivirus?: InputMaybe<ConfigStorageAntivirusUpdateInput>;
|
||||
rateLimit?: InputMaybe<ConfigRateLimitUpdateInput>;
|
||||
resources?: InputMaybe<ConfigResourcesUpdateInput>;
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
@@ -22805,7 +22914,7 @@ export type GetAuthenticationSettingsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAuthenticationSettingsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', auth?: { __typename: 'ConfigAuth', version?: string | null, id: 'ConfigAuth', redirections?: { __typename?: 'ConfigAuthRedirections', clientUrl?: any | null, allowedUrls?: Array<string> | null } | null, totp?: { __typename?: 'ConfigAuthTotp', enabled?: boolean | null, issuer?: string | null } | null, signUp?: { __typename?: 'ConfigAuthSignUp', enabled?: boolean | null } | null, session?: { __typename?: 'ConfigAuthSession', accessToken?: { __typename?: 'ConfigAuthSessionAccessToken', expiresIn?: any | null } | null, refreshToken?: { __typename?: 'ConfigAuthSessionRefreshToken', expiresIn?: any | null } | null } | null, resources?: { __typename?: 'ConfigResources', networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null, user?: { __typename?: 'ConfigAuthUser', email?: { __typename?: 'ConfigAuthUserEmail', allowed?: Array<any> | null, blocked?: Array<any> | null } | null, emailDomains?: { __typename?: 'ConfigAuthUserEmailDomains', allowed?: Array<string> | null, blocked?: Array<string> | null } | null, gravatar?: { __typename?: 'ConfigAuthUserGravatar', enabled?: boolean | null, default?: string | null, rating?: string | null } | null, locale?: { __typename?: 'ConfigAuthUserLocale', allowed?: Array<any> | null, default?: any | null } | null } | null } | null } | null };
|
||||
export type GetAuthenticationSettingsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', auth?: { __typename: 'ConfigAuth', version?: string | null, id: 'ConfigAuth', redirections?: { __typename?: 'ConfigAuthRedirections', clientUrl?: any | null, allowedUrls?: Array<string> | null } | null, totp?: { __typename?: 'ConfigAuthTotp', enabled?: boolean | null, issuer?: string | null } | null, signUp?: { __typename?: 'ConfigAuthSignUp', enabled?: boolean | null } | null, session?: { __typename?: 'ConfigAuthSession', accessToken?: { __typename?: 'ConfigAuthSessionAccessToken', expiresIn?: any | null } | null, refreshToken?: { __typename?: 'ConfigAuthSessionRefreshToken', expiresIn?: any | null } | null } | null, resources?: { __typename?: 'ConfigResources', networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null, user?: { __typename?: 'ConfigAuthUser', email?: { __typename?: 'ConfigAuthUserEmail', allowed?: Array<any> | null, blocked?: Array<any> | null } | null, emailDomains?: { __typename?: 'ConfigAuthUserEmailDomains', allowed?: Array<string> | null, blocked?: Array<string> | null } | null, gravatar?: { __typename?: 'ConfigAuthUserGravatar', enabled?: boolean | null, default?: string | null, rating?: string | null } | null, locale?: { __typename?: 'ConfigAuthUserLocale', allowed?: Array<any> | null, default?: any | null } | null } | null, misc?: { __typename?: 'ConfigAuthMisc', concealErrors?: boolean | null } | null } | null } | null };
|
||||
|
||||
export type GetPostgresSettingsQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
@@ -23010,6 +23119,22 @@ export type GetConfigRawJsonQueryVariables = Exact<{
|
||||
|
||||
export type GetConfigRawJsonQuery = { __typename?: 'query_root', configRawJSON: string };
|
||||
|
||||
export type GetRateLimitConfigQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
resolve: Scalars['Boolean'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetRateLimitConfigQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }, storage?: { __typename?: 'ConfigStorage', rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null } | null, functions?: { __typename?: 'ConfigFunctions', rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null } | null, auth?: { __typename?: 'ConfigAuth', rateLimit?: { __typename?: 'ConfigAuthRateLimit', bruteForce?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, emails?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, global?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, signups?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, sms?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null } | null } | null } | null };
|
||||
|
||||
export type UpdateRateLimitConfigMutationVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
config: ConfigConfigUpdateInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateRateLimitConfigMutation = { __typename?: 'mutation_root', updateConfig: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null }, storage?: { __typename?: 'ConfigStorage', rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null } | null, functions?: { __typename?: 'ConfigFunctions', rateLimit?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null } | null, auth?: { __typename?: 'ConfigAuth', rateLimit?: { __typename?: 'ConfigAuthRateLimit', bruteForce?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, emails?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, global?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, signups?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null, sms?: { __typename?: 'ConfigRateLimit', limit: any, interval: string } | null } | null } | null } };
|
||||
|
||||
export type ReplaceConfigRawJsonMutationVariables = Exact<{
|
||||
appID: Scalars['uuid'];
|
||||
rawJSON: Scalars['String'];
|
||||
@@ -23082,6 +23207,14 @@ export type UpdateConfigMutationVariables = Exact<{
|
||||
|
||||
export type UpdateConfigMutation = { __typename?: 'mutation_root', updateConfig: { __typename?: 'ConfigConfig', id: 'ConfigConfig', postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage?: { __typename?: 'ConfigPostgresStorage', capacity: any } | null } | null } | null, ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', organization?: string | null, apiKey: string }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } };
|
||||
|
||||
export type UpdateDatabaseVersionMutationVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
version: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateDatabaseVersionMutation = { __typename?: 'mutation_root', changeDatabaseVersion: boolean };
|
||||
|
||||
export type UnpauseApplicationMutationVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -23204,6 +23337,16 @@ export type GetServiceLabelValuesQueryVariables = Exact<{
|
||||
|
||||
export type GetServiceLabelValuesQuery = { __typename?: 'query_root', getServiceLabelValues: Array<string> };
|
||||
|
||||
export type GetSystemLogsQueryVariables = Exact<{
|
||||
appID: Scalars['String'];
|
||||
action: Scalars['String'];
|
||||
from?: InputMaybe<Scalars['Timestamp']>;
|
||||
to?: InputMaybe<Scalars['Timestamp']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetSystemLogsQuery = { __typename?: 'query_root', systemLogs: Array<{ __typename?: 'Log', timestamp: any, log: string }> };
|
||||
|
||||
export type DeletePaymentMethodMutationVariables = Exact<{
|
||||
paymentMethodId: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -23375,7 +23518,7 @@ 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 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 }> | 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 }, 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'];
|
||||
@@ -23385,7 +23528,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 }> | 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 }, 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'];
|
||||
@@ -23393,7 +23536,25 @@ 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 }> | 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 }, 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 };
|
||||
|
||||
export type GetRunServicesRateLimitQueryVariables = Exact<{
|
||||
appID: Scalars['uuid'];
|
||||
resolve: Scalars['Boolean'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetRunServicesRateLimitQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', runServices: Array<{ __typename?: 'run_service', id: any, createdAt: any, updatedAt: any, subdomain: string, config?: { __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 } | null }> } | null };
|
||||
|
||||
export type GetLocalRunServiceRateLimitQueryVariables = Exact<{
|
||||
appID: Scalars['uuid'];
|
||||
resolve: Scalars['Boolean'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetLocalRunServiceRateLimitQuery = { __typename?: 'query_root', runServiceConfigs: Array<{ __typename?: 'ConfigRunServiceConfigWithID', serviceID: any, config: { __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 } }> };
|
||||
|
||||
export type InsertRunServiceMutationVariables = Exact<{
|
||||
object: Run_Service_Insert_Input;
|
||||
@@ -23856,6 +24017,10 @@ export const RunServiceConfigFragmentDoc = gql`
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
healthCheck {
|
||||
port
|
||||
@@ -23864,6 +24029,23 @@ export const RunServiceConfigFragmentDoc = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const RunServiceRateLimitFragmentDoc = gql`
|
||||
fragment RunServiceRateLimit on ConfigRunServiceConfig {
|
||||
name
|
||||
ports {
|
||||
port
|
||||
type
|
||||
publish
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const GetWorkspaceMembersWorkspaceMemberFragmentDoc = gql`
|
||||
fragment getWorkspaceMembersWorkspaceMember on workspaceMembers {
|
||||
id
|
||||
@@ -24176,6 +24358,9 @@ export const GetAuthenticationSettingsDocument = gql`
|
||||
default
|
||||
}
|
||||
}
|
||||
misc {
|
||||
concealErrors
|
||||
}
|
||||
version
|
||||
}
|
||||
}
|
||||
@@ -25353,6 +25538,161 @@ export type GetConfigRawJsonQueryResult = Apollo.QueryResult<GetConfigRawJsonQue
|
||||
export function refetchGetConfigRawJsonQuery(variables: GetConfigRawJsonQueryVariables) {
|
||||
return { query: GetConfigRawJsonDocument, variables: variables }
|
||||
}
|
||||
export const GetRateLimitConfigDocument = gql`
|
||||
query getRateLimitConfig($appId: uuid!, $resolve: Boolean!) {
|
||||
config(appID: $appId, resolve: $resolve) {
|
||||
hasura {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
storage {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
functions {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
auth {
|
||||
rateLimit {
|
||||
bruteForce {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
emails {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
global {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
signups {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
sms {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetRateLimitConfigQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetRateLimitConfigQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetRateLimitConfigQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetRateLimitConfigQuery({
|
||||
* variables: {
|
||||
* appId: // value for 'appId'
|
||||
* resolve: // value for 'resolve'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetRateLimitConfigQuery(baseOptions: Apollo.QueryHookOptions<GetRateLimitConfigQuery, GetRateLimitConfigQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetRateLimitConfigQuery, GetRateLimitConfigQueryVariables>(GetRateLimitConfigDocument, options);
|
||||
}
|
||||
export function useGetRateLimitConfigLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetRateLimitConfigQuery, GetRateLimitConfigQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetRateLimitConfigQuery, GetRateLimitConfigQueryVariables>(GetRateLimitConfigDocument, options);
|
||||
}
|
||||
export type GetRateLimitConfigQueryHookResult = ReturnType<typeof useGetRateLimitConfigQuery>;
|
||||
export type GetRateLimitConfigLazyQueryHookResult = ReturnType<typeof useGetRateLimitConfigLazyQuery>;
|
||||
export type GetRateLimitConfigQueryResult = Apollo.QueryResult<GetRateLimitConfigQuery, GetRateLimitConfigQueryVariables>;
|
||||
export function refetchGetRateLimitConfigQuery(variables: GetRateLimitConfigQueryVariables) {
|
||||
return { query: GetRateLimitConfigDocument, variables: variables }
|
||||
}
|
||||
export const UpdateRateLimitConfigDocument = gql`
|
||||
mutation UpdateRateLimitConfig($appId: uuid!, $config: ConfigConfigUpdateInput!) {
|
||||
updateConfig(appID: $appId, config: $config) {
|
||||
hasura {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
storage {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
functions {
|
||||
rateLimit {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
auth {
|
||||
rateLimit {
|
||||
bruteForce {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
emails {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
global {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
signups {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
sms {
|
||||
limit
|
||||
interval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UpdateRateLimitConfigMutationFn = Apollo.MutationFunction<UpdateRateLimitConfigMutation, UpdateRateLimitConfigMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateRateLimitConfigMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateRateLimitConfigMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateRateLimitConfigMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateRateLimitConfigMutation, { data, loading, error }] = useUpdateRateLimitConfigMutation({
|
||||
* variables: {
|
||||
* appId: // value for 'appId'
|
||||
* config: // value for 'config'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateRateLimitConfigMutation(baseOptions?: Apollo.MutationHookOptions<UpdateRateLimitConfigMutation, UpdateRateLimitConfigMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateRateLimitConfigMutation, UpdateRateLimitConfigMutationVariables>(UpdateRateLimitConfigDocument, options);
|
||||
}
|
||||
export type UpdateRateLimitConfigMutationHookResult = ReturnType<typeof useUpdateRateLimitConfigMutation>;
|
||||
export type UpdateRateLimitConfigMutationResult = Apollo.MutationResult<UpdateRateLimitConfigMutation>;
|
||||
export type UpdateRateLimitConfigMutationOptions = Apollo.BaseMutationOptions<UpdateRateLimitConfigMutation, UpdateRateLimitConfigMutationVariables>;
|
||||
export const ReplaceConfigRawJsonDocument = gql`
|
||||
mutation ReplaceConfigRawJSON($appID: uuid!, $rawJSON: String!) {
|
||||
replaceConfigRawJSON(appID: $appID, rawJSON: $rawJSON)
|
||||
@@ -25854,6 +26194,38 @@ export function useUpdateConfigMutation(baseOptions?: Apollo.MutationHookOptions
|
||||
export type UpdateConfigMutationHookResult = ReturnType<typeof useUpdateConfigMutation>;
|
||||
export type UpdateConfigMutationResult = Apollo.MutationResult<UpdateConfigMutation>;
|
||||
export type UpdateConfigMutationOptions = Apollo.BaseMutationOptions<UpdateConfigMutation, UpdateConfigMutationVariables>;
|
||||
export const UpdateDatabaseVersionDocument = gql`
|
||||
mutation UpdateDatabaseVersion($appId: uuid!, $version: String!) {
|
||||
changeDatabaseVersion(appID: $appId, version: $version)
|
||||
}
|
||||
`;
|
||||
export type UpdateDatabaseVersionMutationFn = Apollo.MutationFunction<UpdateDatabaseVersionMutation, UpdateDatabaseVersionMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateDatabaseVersionMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateDatabaseVersionMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateDatabaseVersionMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateDatabaseVersionMutation, { data, loading, error }] = useUpdateDatabaseVersionMutation({
|
||||
* variables: {
|
||||
* appId: // value for 'appId'
|
||||
* version: // value for 'version'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateDatabaseVersionMutation(baseOptions?: Apollo.MutationHookOptions<UpdateDatabaseVersionMutation, UpdateDatabaseVersionMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateDatabaseVersionMutation, UpdateDatabaseVersionMutationVariables>(UpdateDatabaseVersionDocument, options);
|
||||
}
|
||||
export type UpdateDatabaseVersionMutationHookResult = ReturnType<typeof useUpdateDatabaseVersionMutation>;
|
||||
export type UpdateDatabaseVersionMutationResult = Apollo.MutationResult<UpdateDatabaseVersionMutation>;
|
||||
export type UpdateDatabaseVersionMutationOptions = Apollo.BaseMutationOptions<UpdateDatabaseVersionMutation, UpdateDatabaseVersionMutationVariables>;
|
||||
export const UnpauseApplicationDocument = gql`
|
||||
mutation UnpauseApplication($appId: uuid!) {
|
||||
updateApp(pk_columns: {id: $appId}, _set: {desiredState: 5}) {
|
||||
@@ -26441,6 +26813,48 @@ export type GetServiceLabelValuesQueryResult = Apollo.QueryResult<GetServiceLabe
|
||||
export function refetchGetServiceLabelValuesQuery(variables: GetServiceLabelValuesQueryVariables) {
|
||||
return { query: GetServiceLabelValuesDocument, variables: variables }
|
||||
}
|
||||
export const GetSystemLogsDocument = gql`
|
||||
query getSystemLogs($appID: String!, $action: String!, $from: Timestamp, $to: Timestamp) {
|
||||
systemLogs(appID: $appID, action: $action, from: $from) {
|
||||
timestamp
|
||||
log
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetSystemLogsQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetSystemLogsQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetSystemLogsQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetSystemLogsQuery({
|
||||
* variables: {
|
||||
* appID: // value for 'appID'
|
||||
* action: // value for 'action'
|
||||
* from: // value for 'from'
|
||||
* to: // value for 'to'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetSystemLogsQuery(baseOptions: Apollo.QueryHookOptions<GetSystemLogsQuery, GetSystemLogsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetSystemLogsQuery, GetSystemLogsQueryVariables>(GetSystemLogsDocument, options);
|
||||
}
|
||||
export function useGetSystemLogsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSystemLogsQuery, GetSystemLogsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetSystemLogsQuery, GetSystemLogsQueryVariables>(GetSystemLogsDocument, options);
|
||||
}
|
||||
export type GetSystemLogsQueryHookResult = ReturnType<typeof useGetSystemLogsQuery>;
|
||||
export type GetSystemLogsLazyQueryHookResult = ReturnType<typeof useGetSystemLogsLazyQuery>;
|
||||
export type GetSystemLogsQueryResult = Apollo.QueryResult<GetSystemLogsQuery, GetSystemLogsQueryVariables>;
|
||||
export function refetchGetSystemLogsQuery(variables: GetSystemLogsQueryVariables) {
|
||||
return { query: GetSystemLogsDocument, variables: variables }
|
||||
}
|
||||
export const DeletePaymentMethodDocument = gql`
|
||||
mutation deletePaymentMethod($paymentMethodId: uuid!) {
|
||||
deletePaymentMethod(id: $paymentMethodId) {
|
||||
@@ -27480,6 +27894,95 @@ export type GetLocalRunServiceConfigsQueryResult = Apollo.QueryResult<GetLocalRu
|
||||
export function refetchGetLocalRunServiceConfigsQuery(variables: GetLocalRunServiceConfigsQueryVariables) {
|
||||
return { query: GetLocalRunServiceConfigsDocument, variables: variables }
|
||||
}
|
||||
export const GetRunServicesRateLimitDocument = gql`
|
||||
query getRunServicesRateLimit($appID: uuid!, $resolve: Boolean!) {
|
||||
app(id: $appID) {
|
||||
runServices {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
subdomain
|
||||
config(resolve: $resolve) {
|
||||
...RunServiceRateLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${RunServiceRateLimitFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useGetRunServicesRateLimitQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetRunServicesRateLimitQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetRunServicesRateLimitQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetRunServicesRateLimitQuery({
|
||||
* variables: {
|
||||
* appID: // value for 'appID'
|
||||
* resolve: // value for 'resolve'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetRunServicesRateLimitQuery(baseOptions: Apollo.QueryHookOptions<GetRunServicesRateLimitQuery, GetRunServicesRateLimitQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetRunServicesRateLimitQuery, GetRunServicesRateLimitQueryVariables>(GetRunServicesRateLimitDocument, options);
|
||||
}
|
||||
export function useGetRunServicesRateLimitLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetRunServicesRateLimitQuery, GetRunServicesRateLimitQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetRunServicesRateLimitQuery, GetRunServicesRateLimitQueryVariables>(GetRunServicesRateLimitDocument, options);
|
||||
}
|
||||
export type GetRunServicesRateLimitQueryHookResult = ReturnType<typeof useGetRunServicesRateLimitQuery>;
|
||||
export type GetRunServicesRateLimitLazyQueryHookResult = ReturnType<typeof useGetRunServicesRateLimitLazyQuery>;
|
||||
export type GetRunServicesRateLimitQueryResult = Apollo.QueryResult<GetRunServicesRateLimitQuery, GetRunServicesRateLimitQueryVariables>;
|
||||
export function refetchGetRunServicesRateLimitQuery(variables: GetRunServicesRateLimitQueryVariables) {
|
||||
return { query: GetRunServicesRateLimitDocument, variables: variables }
|
||||
}
|
||||
export const GetLocalRunServiceRateLimitDocument = gql`
|
||||
query getLocalRunServiceRateLimit($appID: uuid!, $resolve: Boolean!) {
|
||||
runServiceConfigs(appID: $appID, resolve: $resolve) {
|
||||
serviceID
|
||||
config {
|
||||
...RunServiceRateLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
${RunServiceRateLimitFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useGetLocalRunServiceRateLimitQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetLocalRunServiceRateLimitQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetLocalRunServiceRateLimitQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetLocalRunServiceRateLimitQuery({
|
||||
* variables: {
|
||||
* appID: // value for 'appID'
|
||||
* resolve: // value for 'resolve'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetLocalRunServiceRateLimitQuery(baseOptions: Apollo.QueryHookOptions<GetLocalRunServiceRateLimitQuery, GetLocalRunServiceRateLimitQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetLocalRunServiceRateLimitQuery, GetLocalRunServiceRateLimitQueryVariables>(GetLocalRunServiceRateLimitDocument, options);
|
||||
}
|
||||
export function useGetLocalRunServiceRateLimitLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetLocalRunServiceRateLimitQuery, GetLocalRunServiceRateLimitQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetLocalRunServiceRateLimitQuery, GetLocalRunServiceRateLimitQueryVariables>(GetLocalRunServiceRateLimitDocument, options);
|
||||
}
|
||||
export type GetLocalRunServiceRateLimitQueryHookResult = ReturnType<typeof useGetLocalRunServiceRateLimitQuery>;
|
||||
export type GetLocalRunServiceRateLimitLazyQueryHookResult = ReturnType<typeof useGetLocalRunServiceRateLimitLazyQuery>;
|
||||
export type GetLocalRunServiceRateLimitQueryResult = Apollo.QueryResult<GetLocalRunServiceRateLimitQuery, GetLocalRunServiceRateLimitQueryVariables>;
|
||||
export function refetchGetLocalRunServiceRateLimitQuery(variables: GetLocalRunServiceRateLimitQueryVariables) {
|
||||
return { query: GetLocalRunServiceRateLimitDocument, variables: variables }
|
||||
}
|
||||
export const InsertRunServiceDocument = gql`
|
||||
mutation insertRunService($object: run_service_insert_input!) {
|
||||
insertRunService(object: $object) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
||||
import type {
|
||||
ConfigRunServicePort,
|
||||
DeploymentRowFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import slugify from 'slugify';
|
||||
|
||||
export function getLastLiveDeployment(deployments?: DeploymentRowFragment[]) {
|
||||
@@ -108,3 +111,22 @@ export const removeTypename = (obj: any) => {
|
||||
});
|
||||
return newObj;
|
||||
};
|
||||
|
||||
export const getRunServicePortURL = (
|
||||
subdomain: string,
|
||||
regionName: string,
|
||||
regionDomain: string,
|
||||
port: Partial<ConfigRunServicePort>,
|
||||
) => {
|
||||
const { port: servicePort, ingresses } = port;
|
||||
|
||||
const customDomain = ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
if (customDomain) {
|
||||
return `https://${customDomain}`;
|
||||
}
|
||||
|
||||
const servicePortNumber =
|
||||
Number(servicePort) > 0 ? Number(servicePort) : '[port]';
|
||||
return `https://${subdomain}-${servicePortNumber}.svc.${regionName}.${regionDomain}`;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 52a38fe: chore: added pg_ivm extension
|
||||
|
||||
## 2.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- db2f44d: fix: update rate-limit to reflect reality
|
||||
- dda0c67: chore: udpate metrics documentation with managed configuration
|
||||
|
||||
## 2.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cffdec5: feat: update react quickstart guide to use the nhost react apollo template
|
||||
- 4cf6677: feat: update list of postgres extensions
|
||||
|
||||
## 2.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ba55c1b: feat: run: added a guide on using a private registry
|
||||
- 3d70c63: feat: added rate-limiter guide for auth service
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 40c0d7b: │feat: added subdomain/region information
|
||||
- a18b545: feat: added postgres upgrade docs
|
||||
|
||||
## 2.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4564232: chore: update `clientStorage` docs and add usage examples
|
||||
|
||||
## 2.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
title: Connect Devices to Local Nhost Project
|
||||
description: Configuring dnsmasq for network device connectivity to a local Nhost project
|
||||
icon: ethernet
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
If you want to connect to your local environment from other devices on the same network, such as Android emulators
|
||||
or iPhone devices, you can use **dnsmasq**. Follow this guide for the necessary configuration steps to enable this
|
||||
functionality for your local Nhost project running on your machine.
|
||||
|
||||
<Note>
|
||||
Make sure to install **dnsmasq**. If you're using another OS, please refer to the [dnsmasq website](https://thekelleys.org.uk/dnsmasq/doc.html).
|
||||
|
||||
<Tabs>
|
||||
<Tab title="macOS">
|
||||
```shell Terminal
|
||||
brew install dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Debian">
|
||||
```shell Terminal
|
||||
apt-get install dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Nix">
|
||||
```shell Terminal
|
||||
nix-env -iA nixpkgs.dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Note>
|
||||
|
||||
# Configure dnsmasq for Android
|
||||
<Warning>These steps are necessary when running on both an **Android emulator** or **physical Android device**</Warning>
|
||||
<Steps>
|
||||
<Step title="Configure dnsmasq">
|
||||
Configure `dnsmasq` to resolve nhost service urls to your machine's special [loopback address](https://developer.android.com/studio/run/emulator-networking) `10.0.2.2`
|
||||
```shell Terminal
|
||||
sudo dnsmasq -d \
|
||||
--address=/local.auth.nhost.run/10.0.2.2 \
|
||||
--address=/local.graphql.nhost.run/10.0.2.2 \
|
||||
--address=/local.storage.nhost.run/10.0.2.2 \
|
||||
--address=/local.functions.nhost.run/10.0.2.2
|
||||
```
|
||||
</Step>
|
||||
<Step title="Restart dnsmasq">
|
||||
If you're using another OS, please refer to the [dnsmasq website](https://thekelleys.org.uk/dnsmasq/doc.html).
|
||||
<Tabs>
|
||||
<Tab title="macOS">
|
||||
```shell Terminal
|
||||
sudo brew services restart dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Debian">
|
||||
```shell Terminal
|
||||
sudo systemctl restart dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step title="Configure the android device/emulator's DNS settings">
|
||||
1. Edit your network settings: Settings > Network & Internet > Internet > AndroidWifi
|
||||
2. set `IP settings` to `Static`
|
||||
3. set `DNS 1` and `DNS 2` to `10.0.2.2`
|
||||
4. Save
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
# Configure dnsmasq for iOS
|
||||
<Warning>These steps are only necessary when running on physical iOS device, the iOS simulator uses the host machine's network, so no additional configuration is typically needed.</Warning>
|
||||
<Steps>
|
||||
<Step title="Inspect your machine's IP address on your network">
|
||||
<Tabs>
|
||||
<Tab title="macOS">
|
||||
```shell Terminal
|
||||
ipconfig getifaddr en0
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Debian">
|
||||
```shell Terminal
|
||||
ip addr show dev en0 | grep 'inet ' | awk '{print $2}' | cut -d/ -f1
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step title="Configure dnsmasq">
|
||||
Configure `dnsmasq` to resolve nhost service urls to your machine's ip address.
|
||||
<Warning>Make sure to replace every occurrence of **[your-machine-s-up-address]** with the address printed in Step `1`</Warning>
|
||||
|
||||
```shell Terminal
|
||||
sudo dnsmasq -d \
|
||||
--address=/local.auth.nhost.run/[your-machine-s-up-address] \
|
||||
--address=/local.graphql.nhost.run/[your-machine-s-up-address] \
|
||||
--address=/local.storage.nhost.run/[your-machine-s-up-address] \
|
||||
--address=/local.functions.nhost.run/[your-machine-s-up-address]
|
||||
```
|
||||
</Step>
|
||||
<Step title="Restart dnsmasq">
|
||||
If you're using another OS, please refer to the [dnsmasq website](https://thekelleys.org.uk/dnsmasq/doc.html).
|
||||
<Tabs>
|
||||
<Tab title="macOS">
|
||||
```shell Terminal
|
||||
sudo brew services restart dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Debian">
|
||||
```shell Terminal
|
||||
sudo systemctl restart dnsmasq
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step title="Configure the iPhone's DNS settings">
|
||||
1. Select the wifi you're connected and select `Configure DNS`
|
||||
2. Select `Manual`
|
||||
3. Click on `Add server` and type your local machine's IP address printed in Step `1`
|
||||
4. Save
|
||||
</Step>
|
||||
</Steps>
|
||||
119
docs/guides/cli/subdomain.mdx
Normal file
119
docs/guides/cli/subdomain.mdx
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Subdomain/Region
|
||||
description: Connecting to your local environment
|
||||
icon: compass
|
||||
---
|
||||
|
||||
When you start the CLI the services are exposed similarly to the way they are exposed in the [cloud](/platform/subdomain). For instance, the following information is shown on your terminal after running `nhost up`
|
||||
|
||||
```
|
||||
> nhost up
|
||||
...
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/local
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
Region: local
|
||||
```
|
||||
|
||||
There you can see the various URLs you can use to access each service plus the region and subdomain you can use to configure the SDK:
|
||||
|
||||
```ts
|
||||
// Create a new Nhost client for local development.
|
||||
const nhost = new NhostClient(
|
||||
{ region: 'local', subdomain: 'local' }
|
||||
)
|
||||
```
|
||||
|
||||
The domains in the URLs above will all return the IP address for localhost, `127.0.0.1`, which should suffice for most development environments. For instance:
|
||||
|
||||
```
|
||||
> host local.auth.local.nhost.run
|
||||
local.auth.local.nhost.run has address 127.0.0.1
|
||||
```
|
||||
|
||||
However, those URLs are powered by a dynamic DNS that can return any IPv4 address you need, you just need to replace the subdomain `local` with a `subdomain` that contains the 4 octets of the IPv4 adress you want separated by `-`. For instance:
|
||||
|
||||
```
|
||||
> host 192-168-100-1.auth.local.nhost.run
|
||||
192-168-100-1.auth.local.nhost.run has address 192.168.100.1
|
||||
|
||||
> host 10-10-1-108.auth.local.nhost.run
|
||||
10-10-1-108.auth.local.nhost.run has address 10.10.1.108
|
||||
```
|
||||
|
||||
This is useful if you need to connect to your environment from a different device, a VM or a mobile device emulator.
|
||||
|
||||
To make use of this functionality you can start your development environment after setting the environment variable `NHOST_LOCAL_SUBDOMAIN` or passing the flag `--local-subdomain` :
|
||||
|
||||
```
|
||||
> export NHOST_LOCAL_SUBDOMAIN=192-168-1-1-8 # either this or --local-subdomain 192-168-1-108
|
||||
> nhost --local-subdomain 192-168-1-108 up
|
||||
...
|
||||
Nhost development environment started.
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/local
|
||||
- Hasura: https://192-168-1-108.hasura.local.nhost.run
|
||||
- GraphQL: https://192-168-1-108.graphql.local.nhost.run
|
||||
- Auth: https://192-168-1-108.auth.local.nhost.run
|
||||
- Storage: https://192-168-1-108.storage.local.nhost.run
|
||||
- Functions: https://192-168-1-108.functions.local.nhost.run
|
||||
- Dashboard: https://192-168-1-108.dashboard.local.nhost.run
|
||||
- Mailhog: https://192-168-1-108.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: 192-168-1-108
|
||||
Region: local
|
||||
Run `nhost up` to reload the development environment
|
||||
Run `nhost down` to stop the development environment
|
||||
Run `nhost logs` to watch the logs
|
||||
```
|
||||
|
||||
Now you can configure the SDK with:
|
||||
|
||||
```ts
|
||||
// Create a new Nhost client for local development.
|
||||
const nhost = new NhostClient(
|
||||
{ region: 'local', subdomain: '192-168-1-108' }
|
||||
)
|
||||
```
|
||||
|
||||
<Warning>
|
||||
If you are trying to connect to your local environment from an external device or VM make sure that:
|
||||
|
||||
- The IP address you are using is reachable from this device/VM
|
||||
- That your firewall isn't blocking requests
|
||||
</Warning>
|
||||
|
||||
<Warning>
|
||||
If you are testing a social provider don't forget you will need to configure the callback URL to match the subdomain/region you are using. The dashboard should be able to provide this information in settings page.
|
||||
</Warning>
|
||||
|
||||
## Offline access
|
||||
|
||||
All the URLs in this document are resolved by a public DNS, which means you need Internet access to resolve them. If you need to use any of those URLs without Internet access you can add them to your `/etc/hosts` file. For instance:
|
||||
|
||||
```
|
||||
> cat /etc/hosts
|
||||
##
|
||||
# Host Database
|
||||
#
|
||||
# localhost is used to configure the loopback interface
|
||||
# when the system is booting. Do not change this entry.
|
||||
##
|
||||
127.0.0.1 localhost
|
||||
255.255.255.255 broadcasthost
|
||||
# ::1 localhost
|
||||
|
||||
127.0.0.1 local.auth.local.nhost.run local.storage.local.nhost.run ...
|
||||
```
|
||||
|
||||
Just start with the IP you want to resolve followed by all the entries you need separated by spaces.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user