Compare commits
53 Commits
@nhost/das
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
18a64555ce | ||
|
|
60bcd8f949 | ||
|
|
e28975d6a5 | ||
|
|
33284d3cf0 | ||
|
|
1dbd65eb0e | ||
|
|
6eec78f9c5 | ||
|
|
e3f0732108 | ||
|
|
807b8c049a | ||
|
|
998c0376bf | ||
|
|
cf5423dac6 | ||
|
|
a2efeed36f | ||
|
|
533b74d82d | ||
|
|
42cf86c8f1 | ||
|
|
70e74f2f3d | ||
|
|
a01985466e | ||
|
|
8ea4210582 | ||
|
|
58919ba763 | ||
|
|
86f3f8d505 | ||
|
|
201abb89fd | ||
|
|
cf6b712b20 | ||
|
|
b51986289d |
@@ -12,7 +12,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.10.5
|
||||
run_install: false
|
||||
|
||||
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 }}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -17,5 +17,10 @@ NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
||||
NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET=<nhost_bragi_websocket>
|
||||
|
||||
NEXT_PUBLIC_ZENDESK_URL=
|
||||
NEXT_PUBLIC_ZENDESK_API_KEY=
|
||||
NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
link-workspace-packages = false
|
||||
link-workspace-packages = false
|
||||
auto-install-peers = false
|
||||
resolution-mode=highest
|
||||
@@ -1,5 +1,104 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 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
|
||||
|
||||
- 33284d3: fix: don't show double scrollbar in configuration editor
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@12.0.3
|
||||
- @nhost/nextjs@2.1.17
|
||||
|
||||
## 1.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 998c037: fix: align drop-down list in select component
|
||||
- 807b8c0: fix: show city name in region selection for project creation
|
||||
|
||||
## 1.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a2efeed: fix: improve project health error handling, add unknown state and polling interval for health state
|
||||
|
||||
## 1.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8ea4210: fix: error toasts can be closed individually, instead of dismissing all toasts at once
|
||||
- 58919ba: chore: add blink animation when project health service is updating
|
||||
|
||||
## 1.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b519862: fix: get configuration in configuration editor using local development environment
|
||||
|
||||
## 1.18.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(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "1.18.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -48,6 +48,7 @@
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.15.3",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
12
dashboard/public/assets/LockedApp.svg
Normal file
12
dashboard/public/assets/LockedApp.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="72" height="73" viewBox="0 0 72 73" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10501_253)">
|
||||
<path d="M0 8.5C0 4.08172 3.58172 0.5 8 0.5H64C68.4183 0.5 72 4.08172 72 8.5V64.5C72 68.9183 68.4183 72.5 64 72.5H8C3.58172 72.5 0 68.9183 0 64.5V8.5Z" fill="#9C73DF" fill-opacity="0.2"/>
|
||||
<path d="M43.1203 35.5H29.7687C28.7153 35.5 27.8613 36.3954 27.8613 37.5V44.5C27.8613 45.6046 28.7153 46.5 29.7687 46.5H43.1203C44.1737 46.5 45.0276 45.6046 45.0276 44.5V37.5C45.0276 36.3954 44.1737 35.5 43.1203 35.5Z" stroke="#9C73DF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.6758 35.5V31.5C31.6758 30.1739 32.1782 28.9021 33.0724 27.9645C33.9667 27.0268 35.1795 26.5 36.4442 26.5C37.7089 26.5 38.9217 27.0268 39.816 27.9645C40.7102 28.9021 41.2126 30.1739 41.2126 31.5V35.5" stroke="#9C73DF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10501_253">
|
||||
<rect width="72" height="72" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -29,7 +29,7 @@ export default function CountrySelector({
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
className: 'z-[10000] w-[270px]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
@@ -9,11 +8,9 @@ import { Logo } from '@/components/presentational/Logo';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Chip } from '@/components/ui/v2/Chip';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { DevAssistant } from '@/features/ai/DevAssistant';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -38,14 +35,15 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
const { currentProject, refetch: refetchProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
|
||||
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 () => {};
|
||||
}
|
||||
|
||||
@@ -56,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
|
||||
@@ -97,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">
|
||||
@@ -105,25 +110,19 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
</Button>
|
||||
|
||||
{isPlatform && (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
hideChevron
|
||||
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
>
|
||||
Contact us
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<ContactUs
|
||||
className="max-w-md"
|
||||
isTeam={currentProject?.plan?.name === 'Team'}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
<NavLink
|
||||
underline="none"
|
||||
href="/support"
|
||||
className="mr-2 rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
'&:hover': { backgroundColor: 'grey.200' },
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Support
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -47,46 +47,51 @@ export default function SettingsLayout({
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository
|
||||
and you only want these changes to apply to a subset of them,
|
||||
please check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository
|
||||
and you only want these changes to apply to a subset of
|
||||
them, please check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,6 +37,9 @@ const Badge = styled(MaterialBadge)<BadgeProps>(({ theme }) => ({
|
||||
'& .MuiBadge-colorSuccess': {
|
||||
backgroundColor: theme.palette.success.dark,
|
||||
},
|
||||
'& .MuiBadge-colorSecondary': {
|
||||
backgroundColor: theme.palette.grey[500],
|
||||
},
|
||||
}));
|
||||
|
||||
Badge.displayName = 'NhostBadge';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import { test } from 'vitest';
|
||||
import ErrorToast from './ErrorToast';
|
||||
|
||||
const oneMemberByWorkspaceError = {
|
||||
name: 'ApolloError',
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'database query error',
|
||||
extensions: {
|
||||
path: '$.selectionSet.insertApp.args.object',
|
||||
code: 'unexpected',
|
||||
internal: {
|
||||
arguments: [],
|
||||
error: {
|
||||
description: null,
|
||||
exec_status: 'FatalError',
|
||||
hint: null,
|
||||
message:
|
||||
'Only one workspace member is allowed for individual plans',
|
||||
status_code: 'P0001',
|
||||
},
|
||||
prepared: false,
|
||||
statement: '.....',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
protocolErrors: [],
|
||||
clientErrors: [],
|
||||
networkError: null,
|
||||
message: 'database query error',
|
||||
};
|
||||
|
||||
const changeNodeInvalidVersionError = {
|
||||
name: 'ApolloError',
|
||||
graphQLErrors: [
|
||||
{
|
||||
message:
|
||||
'failed to resolve config: failed to validate config: config is not valid: #Config.functions.node.version: 2 errors in empty disjunction: (and 2 more errors)',
|
||||
path: ['replaceConfigRawJSON'],
|
||||
},
|
||||
],
|
||||
protocolErrors: [],
|
||||
clientErrors: [],
|
||||
networkError: null,
|
||||
message:
|
||||
'failed to resolve config: failed to validate config: config is not valid: #Config.functions.node.version: 2 errors in empty disjunction: (and 2 more errors)',
|
||||
};
|
||||
|
||||
test('should render the error message when creating a project with an individual plan in a workspace with multiple users', () => {
|
||||
const errorMessage =
|
||||
'An error occurred while creating the project. Please try again.';
|
||||
render(
|
||||
<ErrorToast
|
||||
isVisible
|
||||
errorMessage={errorMessage}
|
||||
error={oneMemberByWorkspaceError}
|
||||
close={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Only one workspace member is allowed for individual plans/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the error message when changing the node version to an invalid value in configuration editor', () => {
|
||||
const errorMessage =
|
||||
'An error occurred while saving configuration. Please try again.';
|
||||
render(
|
||||
<ErrorToast
|
||||
isVisible
|
||||
errorMessage={errorMessage}
|
||||
error={changeNodeInvalidVersionError}
|
||||
close={() => {}}
|
||||
/>,
|
||||
);
|
||||
const regex =
|
||||
/failed to resolve config: failed to validate config: config is not valid: #Config\.functions\.node\.version: 2 errors in empty disjunction: \(and 2 more errors\)/i;
|
||||
|
||||
expect(screen.getByText(regex)).toBeInTheDocument();
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const getInternalErrorMessage = (
|
||||
const graphqlError = error.graphQLErrors?.[0];
|
||||
const graphqlExtensionsError = graphqlError?.extensions?.internal
|
||||
?.error as { message: string };
|
||||
return graphqlError.message || graphqlExtensionsError?.message || null;
|
||||
return graphqlExtensionsError?.message || graphqlError.message || null;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -8,6 +8,17 @@ import { createTheme as createMuiTheme } from '@mui/material/styles';
|
||||
* @param mode - Color mode
|
||||
* @returns Material UI theme
|
||||
*/
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
interface Palette {
|
||||
beige: Palette['primary'];
|
||||
}
|
||||
|
||||
interface PaletteOptions {
|
||||
beige?: PaletteOptions['primary'];
|
||||
}
|
||||
}
|
||||
|
||||
export default function createTheme(mode: PaletteMode) {
|
||||
return createMuiTheme({
|
||||
shape: {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export default function CommunityIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5.5 10C7.29493 10 8.75 8.54493 8.75 6.75C8.75 4.95507 7.29493 3.5 5.5 3.5C3.70507 3.5 2.25 4.95507 2.25 6.75C2.25 8.54493 3.70507 10 5.5 10Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M9.71338 3.62107C10.1604 3.49513 10.6292 3.46644 11.0882 3.53693C11.5473 3.60743 11.9859 3.77547 12.3745 4.02975C12.7631 4.28403 13.0927 4.61863 13.3411 5.01102C13.5896 5.40342 13.751 5.84449 13.8146 6.30453C13.8782 6.76457 13.8425 7.2329 13.7098 7.67797C13.5772 8.12304 13.3507 8.53452 13.0457 8.8847C12.7406 9.23487 12.364 9.51561 11.9413 9.70799C11.5187 9.90038 11.0596 9.99996 10.5952 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1 12.3373C1.50758 11.6153 2.18143 11.026 2.96466 10.6192C3.74788 10.2124 4.61748 10 5.50005 10C6.38262 9.99997 7.25224 10.2123 8.0355 10.619C8.81875 11.0258 9.49264 11.615 10.0003 12.337"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5952 10C11.4779 9.99936 12.3477 10.2114 13.131 10.6182C13.9143 11.025 14.5881 11.6146 15.0952 12.337"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CommunityIcon } from './CommunityIcon';
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export default function DiscordIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 14 14"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.40571 1.5H12.5943C13.3691 1.5 14 2.13086 14 2.91257V15.2143L12.5257 13.9114L11.696 13.1434L10.8183 12.3274L11.1817 13.596H3.40571C2.63086 13.596 2 12.9651 2 12.1834V2.91257C2 2.13086 2.63086 1.5 3.40571 1.5ZM9.49486 9.9C9.70057 10.1606 9.94743 10.4554 9.94743 10.4554C11.4629 10.4074 12.0457 9.41314 12.0457 9.41314C12.0457 7.20514 11.0583 5.41543 11.0583 5.41543C10.0709 4.67486 9.13143 4.69543 9.13143 4.69543L9.03543 4.80514C10.2011 5.16171 10.7429 5.676 10.7429 5.676C10.0297 5.28514 9.33029 5.09314 8.67886 5.01771C8.18514 4.96286 7.712 4.97657 7.29371 5.03143C7.2578 5.03143 7.2271 5.03665 7.19251 5.04254C7.18748 5.0434 7.18237 5.04427 7.17714 5.04514C6.93714 5.06571 6.35429 5.15486 5.62057 5.47714C5.36686 5.59371 5.216 5.676 5.216 5.676C5.216 5.676 5.78514 5.13429 7.01943 4.77771L6.95086 4.69543C6.95086 4.69543 6.01143 4.67486 5.024 5.41543C5.024 5.41543 4.03657 7.20514 4.03657 9.41314C4.03657 9.41314 4.61257 10.4074 6.128 10.4554C6.128 10.4554 6.38171 10.1469 6.58743 9.88628C5.71657 9.62571 5.38743 9.07714 5.38743 9.07714C5.38743 9.07714 5.456 9.12514 5.57943 9.19371C5.58629 9.20057 5.59314 9.20743 5.60686 9.21428C5.61714 9.22114 5.62743 9.22628 5.63771 9.23143C5.648 9.23657 5.65829 9.24171 5.66857 9.24857C5.84 9.34457 6.01143 9.42 6.16914 9.48171C6.45029 9.59143 6.78629 9.70114 7.17714 9.77657C7.69143 9.87257 8.29486 9.90686 8.95314 9.78343C9.27543 9.72857 9.60457 9.63257 9.94743 9.48857C10.1874 9.39943 10.4549 9.26914 10.736 9.084C10.736 9.084 10.3931 9.64628 9.49486 9.9ZM6.05942 8.01421C6.05942 7.59593 6.36799 7.25307 6.75885 7.25307C7.1497 7.25307 7.46513 7.59593 7.45827 8.01421C7.45827 8.4325 7.1497 8.77536 6.75885 8.77536C6.37485 8.77536 6.05942 8.4325 6.05942 8.01421ZM8.56227 8.01421C8.56227 7.59593 8.87084 7.25307 9.2617 7.25307C9.65256 7.25307 9.96113 7.59593 9.96113 8.01421C9.96113 8.4325 9.65256 8.77536 9.2617 8.77536C8.8777 8.77536 8.56227 8.4325 8.56227 8.01421Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DiscordIcon } from './DiscordIcon';
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export default function EnvelopeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 14 14"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 3.5H14V12C14 12.1326 13.9473 12.2598 13.8536 12.3536C13.7598 12.4473 13.6326 12.5 13.5 12.5H2.5C2.36739 12.5 2.24021 12.4473 2.14645 12.3536C2.05268 12.2598 2 12.1326 2 12V3.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 3.5L8 9L2 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EnvelopeIcon } from './EnvelopeIcon';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function PowerOffIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
d="M18.36 6.64A9 9 0 0 1 20.77 15M6.16 6.16a9 9 0 1 0 12.68 12.68M12 2v4M2 2l20 20"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
PowerOffIcon.displayName = 'NhostPowerOffIcon';
|
||||
|
||||
export default PowerOffIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PowerOffIcon } from './PowerOffIcon';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function QuestionMarkIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="320"
|
||||
height="512"
|
||||
aria-label="Question mark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 320 512"
|
||||
{...props}
|
||||
>
|
||||
<path d="M80 160c0-35.3 28.7-64 64-64h32c35.3 0 64 28.7 64 64v3.6c0 21.8-11.1 42.1-29.4 53.8l-42.2 27.1c-25.2 16.2-40.4 44.1-40.4 74V320c0 17.7 14.3 32 32 32s32-14.3 32-32v-1.4c0-8.2 4.2-15.8 11-20.2l42.2-27.1c36.6-23.6 58.8-64.1 58.8-107.7V160c0-70.7-57.3-128-128-128H144C73.3 32 16 89.3 16 160c0 17.7 14.3 32 32 32s32-14.3 32-32zm80 320a40 40 0 1 0 0-80 40 40 0 1 0 0 80z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
QuestionMarkIcon.displayName = 'NhostQuestionMarkIcon';
|
||||
|
||||
export default QuestionMarkIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as QuestionMarkIcon } from './QuestionMarkIcon';
|
||||
@@ -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';
|
||||
@@ -63,6 +63,9 @@ export default function getDesignTokens(mode: PaletteMode): PaletteOptions {
|
||||
paper: '#171d26',
|
||||
},
|
||||
divider: '#2f363d',
|
||||
beige: {
|
||||
main: '#362c22',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,5 +128,8 @@ export default function getDesignTokens(mode: PaletteMode): PaletteOptions {
|
||||
paper: '#ffffff',
|
||||
},
|
||||
divider: '#eaedf0',
|
||||
beige: {
|
||||
main: '#e5d1bf',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArgumentsFormSection({
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
className: 'z-[10000] w-[270px]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ApplicationLockedReasonProps {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export default function ApplicationLockedReason({
|
||||
reason,
|
||||
}: ApplicationLockedReasonProps) {
|
||||
return (
|
||||
<Alert severity="warning" className="mx-auto max-w-xs gap-2 p-6 ">
|
||||
<Text className="pb-4 text-left">
|
||||
Your project has been temporarily locked due to the following reason:
|
||||
</Text>
|
||||
<Box
|
||||
className="rounded-md p-2"
|
||||
sx={{
|
||||
backgroundColor: 'beige.main',
|
||||
}}
|
||||
>
|
||||
<Text className="px-2 py-1 font-semibold">{reason}</Text>
|
||||
</Box>
|
||||
<Text className="pt-4 text-left">
|
||||
Please{' '}
|
||||
<Link
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href="/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
contact our support
|
||||
</Link>{' '}
|
||||
team for assistance.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApplicationLockedReason } from './ApplicationLockedReason';
|
||||
@@ -2,25 +2,24 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ApplicationInfo } from '@/features/projects/common/components/ApplicationInfo';
|
||||
import { ApplicationLockedReason } from '@/features/projects/common/components/ApplicationLockedReason';
|
||||
import { ApplicationPausedReason } from '@/features/projects/common/components/ApplicationPausedReason';
|
||||
import { ApplicationPausedSymbol } from '@/features/projects/common/components/ApplicationPausedSymbol';
|
||||
import { ChangePlanModal } from '@/features/projects/common/components/ChangePlanModal';
|
||||
import { RemoveApplicationModal } from '@/features/projects/common/components/RemoveApplicationModal';
|
||||
import { StagingMetadata } from '@/features/projects/common/components/StagingMetadata';
|
||||
import { useAppPausedReason } from '@/features/projects/common/hooks/useAppPausedReason';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useUnpauseApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ApplicationPaused() {
|
||||
@@ -28,7 +27,6 @@ export default function ApplicationPaused() {
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const user = useUserData();
|
||||
|
||||
const [showDeletingModal, setShowDeletingModal] = useState(false);
|
||||
const [unpauseApplication, { loading: changingApplicationStateLoading }] =
|
||||
@@ -36,13 +34,8 @@ export default function ApplicationPaused() {
|
||||
refetchQueries: [{ query: GetAllWorkspacesAndProjectsDocument }],
|
||||
});
|
||||
|
||||
const { data, loading } = useGetFreeAndActiveProjectsQuery({
|
||||
variables: { userId: user?.id },
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const numberOfFreeAndLiveProjects = data?.freeAndActiveProjects.length || 0;
|
||||
const wakeUpDisabled = numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
|
||||
const { isLocked, lockedReason, freeAndLiveProjectsNumberExceeded, loading } =
|
||||
useAppPausedReason();
|
||||
|
||||
async function handleTriggerUnpausing() {
|
||||
await execPromiseWithErrorToast(
|
||||
@@ -77,75 +70,67 @@ export default function ApplicationPaused() {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Container className="mx-auto mt-20 grid max-w-lg grid-flow-row gap-4 text-center">
|
||||
<Container className="mx-auto mt-20 grid max-w-lg grid-flow-row gap-6 text-center">
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<Image
|
||||
src="/assets/PausedApp.svg"
|
||||
alt="Closed Eye"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
<ApplicationPausedSymbol isLocked={isLocked} />
|
||||
</div>
|
||||
|
||||
<Box className="grid grid-flow-row gap-1">
|
||||
<Box className="grid grid-flow-row gap-6">
|
||||
<Text variant="h3" component="h1">
|
||||
{currentProject.name} is sleeping
|
||||
{currentProject.name} is {isLocked ? 'locked' : 'paused'}
|
||||
</Text>
|
||||
{isLocked ? (
|
||||
<ApplicationLockedReason reason={lockedReason} />
|
||||
) : (
|
||||
<>
|
||||
<ApplicationPausedReason
|
||||
freeAndLiveProjectsNumberExceeded={
|
||||
freeAndLiveProjectsNumberExceeded
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-flow-row gap-4">
|
||||
{isOwner && (
|
||||
<Button
|
||||
className="mx-auto w-full max-w-xs"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={
|
||||
changingApplicationStateLoading ||
|
||||
freeAndLiveProjectsNumberExceeded
|
||||
}
|
||||
onClick={handleTriggerUnpausing}
|
||||
>
|
||||
Wake Up
|
||||
</Button>
|
||||
|
||||
<Text>
|
||||
Starter projects stop responding to API calls after 7 days of
|
||||
inactivity. Upgrade to Pro to avoid autosleep.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
{isOwner && (
|
||||
<Button
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
{isOwner && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={changingApplicationStateLoading || wakeUpDisabled}
|
||||
onClick={handleTriggerUnpausing}
|
||||
>
|
||||
Wake Up
|
||||
</Button>
|
||||
|
||||
{wakeUpDisabled && (
|
||||
<Alert severity="warning" className="mx-auto max-w-xs text-left">
|
||||
Note: Only one free project can be active at any given time.
|
||||
Please pause your active free project before unpausing{' '}
|
||||
{currentProject.name}.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<StagingMetadata>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
interface ApplicationPausedReasonProps {
|
||||
freeAndLiveProjectsNumberExceeded?: boolean;
|
||||
}
|
||||
|
||||
export default function ApplicationPausedReason({
|
||||
freeAndLiveProjectsNumberExceeded,
|
||||
}: ApplicationPausedReasonProps) {
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="mx-auto flex max-w-xs flex-col gap-4 p-6 text-left"
|
||||
>
|
||||
<Text>
|
||||
Starter projects will stop responding to API calls after 7 days of
|
||||
inactivity, so consider
|
||||
<span className="font-semibold"> upgrading to Pro </span>to avoid
|
||||
auto-sleep.
|
||||
</Text>
|
||||
{freeAndLiveProjectsNumberExceeded && (
|
||||
<Text>
|
||||
Additionally, only 1 free project can be active at any given time, so
|
||||
please pause your current active free project before unpausing
|
||||
another.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApplicationPausedReason } from './ApplicationPausedReason';
|
||||
@@ -0,0 +1,23 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function ApplicationPausedSymbol({
|
||||
isLocked,
|
||||
}: {
|
||||
isLocked?: boolean;
|
||||
}) {
|
||||
if (isLocked) {
|
||||
return (
|
||||
<Image src="/assets/LockedApp.svg" alt="Lock" width={72} height={72} />
|
||||
);
|
||||
}
|
||||
|
||||
// paused
|
||||
return (
|
||||
<Image
|
||||
src="/assets/PausedApp.svg"
|
||||
alt="Closed Eye"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApplicationPausedSymbol } from './ApplicationPausedSymbol';
|
||||
@@ -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}
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function TOMLEditor() {
|
||||
appID: currentProject?.id,
|
||||
},
|
||||
skip: !currentProject,
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const [saveConfigMutation] = useReplaceConfigRawJsonMutation({
|
||||
@@ -142,7 +143,7 @@ export default function TOMLEditor() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col">
|
||||
<>
|
||||
<Box className="flex w-full flex-col space-y-2 border-b p-4">
|
||||
<Text className="font-semibold">Configuration Editor</Text>
|
||||
</Box>
|
||||
@@ -186,6 +187,6 @@ export default function TOMLEditor() {
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useAppPausedReason } from './useAppPausedReason';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
import {
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useGetProjectIsLockedQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
|
||||
/**
|
||||
* This hook returns the reason why the application is paused.
|
||||
* It returns the locked reason and if the user has exceeded the number of free and live projects.
|
||||
*/
|
||||
|
||||
export default function useAppPausedReason(): {
|
||||
isLocked: boolean;
|
||||
lockedReason: string | undefined;
|
||||
freeAndLiveProjectsNumberExceeded: boolean;
|
||||
loading: boolean;
|
||||
} {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const user = useUserData();
|
||||
const { data, loading } = useGetFreeAndActiveProjectsQuery({
|
||||
variables: { userId: user?.id },
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const { data: isLockedData } = useGetProjectIsLockedQuery({
|
||||
variables: { appId: currentProject.id },
|
||||
skip: !currentProject,
|
||||
});
|
||||
|
||||
const isLocked = isLockedData?.app?.isLocked;
|
||||
const lockedReason = isLockedData?.app?.isLockedReason;
|
||||
|
||||
const numberOfFreeAndLiveProjects = data?.freeAndActiveProjects.length || 0;
|
||||
const freeAndLiveProjectsNumberExceeded =
|
||||
numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
|
||||
|
||||
return {
|
||||
isLocked,
|
||||
lockedReason,
|
||||
freeAndLiveProjectsNumberExceeded,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useServiceStatus } from './useServiceStatus';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import type { ServiceHealthInfo } from '@/features/projects/overview/health';
|
||||
import {
|
||||
useGetProjectServicesHealthQuery,
|
||||
type GetProjectServicesHealthQuery,
|
||||
type GetProjectServicesHealthQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useVisibilityChange } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseServiceStatusOptions
|
||||
extends QueryHookOptions<
|
||||
GetProjectServicesHealthQuery,
|
||||
GetProjectServicesHealthQueryVariables
|
||||
> {
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
export default function useServiceStatus(
|
||||
options: UseServiceStatusOptions = {},
|
||||
) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isVisible = useVisibilityChange();
|
||||
|
||||
const { data, loading, refetch, startPolling, stopPolling } =
|
||||
useGetProjectServicesHealthQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentProject?.id },
|
||||
skip: !isPlatform || !currentProject,
|
||||
skipPollAttempt: () => !isVisible,
|
||||
});
|
||||
|
||||
// Fetch when mounted
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.shouldPoll) {
|
||||
startPolling(options.pollInterval || 10000);
|
||||
}
|
||||
|
||||
return () => stopPolling();
|
||||
}, [stopPolling, startPolling, options.shouldPoll, options.pollInterval]);
|
||||
|
||||
const serviceMap: { [key: string]: ServiceHealthInfo | undefined } = {};
|
||||
data?.getProjectStatus?.services.forEach((service) => {
|
||||
serviceMap[service.name] = service;
|
||||
});
|
||||
const {
|
||||
'hasura-auth': auth,
|
||||
'hasura-storage': storage,
|
||||
postgres,
|
||||
hasura,
|
||||
ai,
|
||||
...run
|
||||
} = serviceMap;
|
||||
|
||||
return {
|
||||
loading,
|
||||
auth,
|
||||
storage,
|
||||
postgres,
|
||||
hasura,
|
||||
ai,
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useSoftwareVersionsInfo } from './useSoftwareVersionsInfo';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
Software_Type_Enum,
|
||||
useGetConfiguredVersionsQuery,
|
||||
useGetRecommendedSoftwareVersionsQuery,
|
||||
type GetConfiguredVersionsQuery,
|
||||
type GetConfiguredVersionsQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useVisibilityChange } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseSoftwareVersionsInfoOptions
|
||||
extends QueryHookOptions<
|
||||
GetConfiguredVersionsQuery,
|
||||
GetConfiguredVersionsQueryVariables
|
||||
> {}
|
||||
|
||||
type ServiceVersionInfo = {
|
||||
configuredVersion: string | undefined;
|
||||
recommendedVersions: string[];
|
||||
isVersionMismatch: boolean;
|
||||
};
|
||||
|
||||
export default function useSoftwareVersionsInfo(
|
||||
options: UseSoftwareVersionsInfoOptions = {},
|
||||
): {
|
||||
loading: boolean;
|
||||
auth: ServiceVersionInfo;
|
||||
storage: ServiceVersionInfo;
|
||||
postgres: ServiceVersionInfo;
|
||||
hasura: ServiceVersionInfo;
|
||||
ai: ServiceVersionInfo;
|
||||
isAIEnabled: boolean;
|
||||
} {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isVisible = useVisibilityChange();
|
||||
|
||||
// Recommended software versions are not polled by default
|
||||
const { data: recommendedVersionsData, loading: loadingRecommendedVersions } =
|
||||
useGetRecommendedSoftwareVersionsQuery({
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
|
||||
const {
|
||||
data: configuredVersionsData,
|
||||
loading: loadingConfiguredVersions,
|
||||
refetch: refetchConfiguredVersions,
|
||||
stopPolling,
|
||||
} = useGetConfiguredVersionsQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentProject?.id },
|
||||
skip: !isPlatform || !currentProject,
|
||||
skipPollAttempt: () => !isVisible,
|
||||
pollInterval: options.pollInterval || 10000,
|
||||
});
|
||||
|
||||
// fetch when mounted
|
||||
useEffect(() => {
|
||||
refetchConfiguredVersions();
|
||||
return () => stopPolling();
|
||||
}, [refetchConfiguredVersions, stopPolling]);
|
||||
|
||||
const recommendedVersions = {
|
||||
'hasura-auth': [],
|
||||
'hasura-storage': [],
|
||||
postgres: [],
|
||||
hasura: [],
|
||||
ai: [],
|
||||
};
|
||||
|
||||
recommendedVersionsData?.softwareVersions.forEach(({ software, version }) => {
|
||||
switch (software) {
|
||||
case Software_Type_Enum.Auth:
|
||||
recommendedVersions['hasura-auth'].push(version);
|
||||
break;
|
||||
case Software_Type_Enum.Storage:
|
||||
recommendedVersions['hasura-storage'].push(version);
|
||||
break;
|
||||
case Software_Type_Enum.PostgreSql:
|
||||
recommendedVersions.postgres.push(version);
|
||||
break;
|
||||
case Software_Type_Enum.Hasura:
|
||||
recommendedVersions.hasura.push(version);
|
||||
break;
|
||||
case Software_Type_Enum.Graphite:
|
||||
recommendedVersions.ai.push(version);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const isVersionMismatch = (
|
||||
service: string,
|
||||
configuredVersion: string | undefined,
|
||||
) =>
|
||||
!recommendedVersions[service].some(
|
||||
(version) => version === configuredVersion,
|
||||
);
|
||||
|
||||
// Check if configured version can't be found in recommended versions
|
||||
const isAuthVersionMismatch = isVersionMismatch(
|
||||
'hasura-auth',
|
||||
configuredVersionsData?.config?.auth?.version,
|
||||
);
|
||||
const isStorageVersionMismatch = isVersionMismatch(
|
||||
'hasura-storage',
|
||||
configuredVersionsData?.config?.storage?.version,
|
||||
);
|
||||
const isPostgresVersionMismatch = isVersionMismatch(
|
||||
'postgres',
|
||||
configuredVersionsData?.config?.postgres?.version,
|
||||
);
|
||||
const isHasuraVersionMismatch = isVersionMismatch(
|
||||
'hasura',
|
||||
configuredVersionsData?.config?.hasura?.version,
|
||||
);
|
||||
const isAIVersionMismatch = isVersionMismatch(
|
||||
'ai',
|
||||
configuredVersionsData?.config?.ai?.version,
|
||||
);
|
||||
|
||||
return {
|
||||
loading: loadingConfiguredVersions || loadingRecommendedVersions,
|
||||
auth: {
|
||||
configuredVersion: configuredVersionsData?.config?.auth?.version,
|
||||
recommendedVersions: recommendedVersions['hasura-auth'],
|
||||
isVersionMismatch: isAuthVersionMismatch,
|
||||
},
|
||||
storage: {
|
||||
configuredVersion: configuredVersionsData?.config?.storage?.version,
|
||||
recommendedVersions: recommendedVersions['hasura-storage'],
|
||||
isVersionMismatch: isStorageVersionMismatch,
|
||||
},
|
||||
postgres: {
|
||||
configuredVersion: configuredVersionsData?.config?.postgres?.version,
|
||||
recommendedVersions: recommendedVersions.postgres,
|
||||
isVersionMismatch: isPostgresVersionMismatch,
|
||||
},
|
||||
hasura: {
|
||||
configuredVersion: configuredVersionsData?.config?.hasura?.version,
|
||||
recommendedVersions: recommendedVersions.hasura,
|
||||
isVersionMismatch: isHasuraVersionMismatch,
|
||||
},
|
||||
ai: {
|
||||
configuredVersion: configuredVersionsData?.config?.ai?.version,
|
||||
recommendedVersions: recommendedVersions.ai,
|
||||
isVersionMismatch: isAIVersionMismatch,
|
||||
},
|
||||
isAIEnabled: Boolean(configuredVersionsData?.config?.ai),
|
||||
};
|
||||
}
|
||||
@@ -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,67 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { QuestionMarkIcon } from '@/components/ui/v2/icons/QuestionMarkIcon';
|
||||
import { serviceStateToThemeColor } from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
|
||||
interface AccordionHealthBadgeProps {
|
||||
serviceState?: ServiceState;
|
||||
unknownState?: boolean;
|
||||
/*
|
||||
* Blinking animation to indicate that the service is updating.
|
||||
*/
|
||||
blink?: boolean;
|
||||
}
|
||||
|
||||
export default function AccordionHealthBadge({
|
||||
serviceState,
|
||||
unknownState,
|
||||
blink,
|
||||
}: AccordionHealthBadgeProps) {
|
||||
if (unknownState) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="flex h-2.5 w-2.5 items-center justify-center rounded-full"
|
||||
>
|
||||
<QuestionMarkIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-3/4 w-3/4 stroke-2"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceState === ServiceState.Running) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="flex h-2.5 w-2.5 items-center justify-center rounded-full"
|
||||
>
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-3/4 w-3/4 stroke-2"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className={`h-2.5 w-2.5 rounded-full ${blink ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AccordionHealthBadge } from './AccordionHealthBadge';
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
@@ -9,105 +7,45 @@ import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useServiceStatus } from '@/features/projects/common/hooks/useServiceStatus';
|
||||
import { useSoftwareVersionsInfo } from '@/features/projects/common/hooks/useSoftwareVersionsInfo';
|
||||
import { OverviewProjectHealthModal } from '@/features/projects/overview/components/OverviewProjectHealthModal';
|
||||
import { ProjectHealthCard } from '@/features/projects/overview/components/ProjectHealthCard';
|
||||
import { RunStatusTooltip } from '@/features/projects/overview/components/RunStatusTooltip';
|
||||
import { ServiceVersionTooltip } from '@/features/projects/overview/components/ServiceVersionTooltip';
|
||||
import {
|
||||
baseServices,
|
||||
findHighestImportanceState,
|
||||
serviceStateToThemeColor,
|
||||
type ServiceHealthInfo,
|
||||
} from '@/features/projects/overview/health';
|
||||
import {
|
||||
ServiceState,
|
||||
useGetConfiguredVersionsQuery,
|
||||
useGetProjectServicesHealthQuery,
|
||||
useGetRecommendedSoftwareVersionsQuery,
|
||||
} from '@/generated/graphql';
|
||||
|
||||
interface RunStatusTooltipProps {
|
||||
servicesStatusInfo?: Array<ServiceHealthInfo>;
|
||||
openHealthModal?: (
|
||||
defaultExpanded?: keyof typeof baseServices | 'run',
|
||||
) => void;
|
||||
}
|
||||
|
||||
function RunStatusTooltip({
|
||||
servicesStatusInfo,
|
||||
openHealthModal,
|
||||
}: RunStatusTooltipProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 px-2 py-3">
|
||||
<ol className="m-0 flex flex-col gap-3">
|
||||
{servicesStatusInfo.map((service) => (
|
||||
<li
|
||||
key={service.name}
|
||||
className="flex flex-row items-center gap-4 text-ellipsis text-nowrap leading-5"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(service.state),
|
||||
}}
|
||||
className={`h-3 w-3 flex-shrink-0 rounded-full ${
|
||||
service.state === ServiceState.Updating ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<Text
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
{service.name}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button variant="outlined" onClick={() => openHealthModal('run')}>
|
||||
View state
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewProjectHealth() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { data: recommendedVersionsData, loading: loadingRecommendedVersions } =
|
||||
useGetRecommendedSoftwareVersionsQuery({
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const { data: configuredVersionsData, loading: loadingConfiguredVersions } =
|
||||
useGetConfiguredVersionsQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
const {
|
||||
loading: loadingVersions,
|
||||
auth: authVersionInfo,
|
||||
storage: storageVersionInfo,
|
||||
postgres: postgresVersionInfo,
|
||||
hasura: hasuraVersionInfo,
|
||||
ai: aiVersionInfo,
|
||||
isAIEnabled,
|
||||
} = useSoftwareVersionsInfo();
|
||||
|
||||
const {
|
||||
data: projectServicesHealthData,
|
||||
loading: loadingProjectServicesHealth,
|
||||
} = useGetProjectServicesHealthQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
skip: !isPlatform || !currentProject,
|
||||
auth: authStatus,
|
||||
storage: storageStatus,
|
||||
postgres: postgresStatus,
|
||||
hasura: hasuraStatus,
|
||||
ai: aiStatus,
|
||||
run: runStatus,
|
||||
} = useServiceStatus({
|
||||
shouldPoll: true,
|
||||
});
|
||||
|
||||
if (
|
||||
loadingRecommendedVersions ||
|
||||
loadingConfiguredVersions ||
|
||||
loadingProjectServicesHealth
|
||||
) {
|
||||
if (loadingVersions || loadingProjectServicesHealth) {
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
<Text variant="h3">Project Health</Text>
|
||||
@@ -133,78 +71,12 @@ export default function OverviewProjectHealth() {
|
||||
);
|
||||
}
|
||||
|
||||
const isAIServiceEnabled = !!configuredVersionsData?.config?.ai;
|
||||
|
||||
const getRecommendedVersions = (softwareName: string): string[] =>
|
||||
recommendedVersionsData?.softwareVersions.reduce(
|
||||
(recommendedVersions, service) => {
|
||||
if (service.software === softwareName) {
|
||||
recommendedVersions.push(service.version);
|
||||
}
|
||||
return recommendedVersions;
|
||||
},
|
||||
[],
|
||||
) ?? [];
|
||||
|
||||
const authRecommendedVersions = getRecommendedVersions(
|
||||
baseServices['hasura-auth'].softwareVersionsName,
|
||||
);
|
||||
const hasuraRecommendedVersions = getRecommendedVersions(
|
||||
baseServices.hasura.softwareVersionsName,
|
||||
);
|
||||
const postgresRecommendedVersions = getRecommendedVersions(
|
||||
baseServices.postgres.softwareVersionsName,
|
||||
);
|
||||
const storageRecommendedVersions = getRecommendedVersions(
|
||||
baseServices['hasura-storage'].softwareVersionsName,
|
||||
);
|
||||
const aiRecommendedVersions = getRecommendedVersions(
|
||||
baseServices.ai.softwareVersionsName,
|
||||
);
|
||||
|
||||
// Check if configured version can't be found in recommended versions
|
||||
const isAuthVersionMismatch = !authRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.auth?.version === version,
|
||||
);
|
||||
|
||||
const isHasuraVersionMismatch = !hasuraRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.hasura?.version === version,
|
||||
);
|
||||
|
||||
const isPostgresVersionMismatch = !postgresRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.postgres?.version === version,
|
||||
);
|
||||
|
||||
const isStorageVersionMismatch = !storageRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.storage?.version === version,
|
||||
);
|
||||
|
||||
const isAIVersionMismatch = !aiRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.ai?.version === version,
|
||||
);
|
||||
|
||||
const serviceMap: { [key: string]: ServiceHealthInfo | undefined } = {};
|
||||
projectServicesHealthData?.getProjectStatus?.services.forEach((service) => {
|
||||
serviceMap[service.name] = service;
|
||||
});
|
||||
const {
|
||||
'hasura-auth': authStatus,
|
||||
'hasura-storage': storageStatus,
|
||||
postgres: postgresStatus,
|
||||
hasura: hasuraStatus,
|
||||
ai: aiStatus,
|
||||
...otherServicesStatus
|
||||
} = serviceMap;
|
||||
|
||||
const openHealthModal = async (
|
||||
defaultExpanded: keyof typeof baseServices | 'run',
|
||||
) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<OverviewProjectHealthModal
|
||||
servicesHealth={projectServicesHealthData}
|
||||
defaultExpanded={defaultExpanded}
|
||||
/>
|
||||
<OverviewProjectHealthModal defaultExpanded={defaultExpanded} />
|
||||
),
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-2xl w-full' },
|
||||
@@ -220,9 +92,9 @@ export default function OverviewProjectHealth() {
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices['hasura-auth'].displayName}
|
||||
serviceKey="hasura-auth"
|
||||
usedVersion={configuredVersionsData?.config?.auth?.version ?? ''}
|
||||
recommendedVersionMismatch={isAuthVersionMismatch}
|
||||
recommendedVersions={authRecommendedVersions}
|
||||
usedVersion={authVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={authVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={authVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={authStatus?.state}
|
||||
/>
|
||||
@@ -232,9 +104,9 @@ export default function OverviewProjectHealth() {
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.hasura.displayName}
|
||||
serviceKey="hasura"
|
||||
usedVersion={configuredVersionsData?.config?.hasura?.version ?? ''}
|
||||
recommendedVersionMismatch={isHasuraVersionMismatch}
|
||||
recommendedVersions={hasuraRecommendedVersions}
|
||||
usedVersion={hasuraVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={hasuraVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={hasuraVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={hasuraStatus?.state}
|
||||
/>
|
||||
@@ -244,9 +116,9 @@ export default function OverviewProjectHealth() {
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.postgres.displayName}
|
||||
serviceKey="postgres"
|
||||
usedVersion={configuredVersionsData?.config?.postgres?.version ?? ''}
|
||||
recommendedVersionMismatch={isPostgresVersionMismatch}
|
||||
recommendedVersions={postgresRecommendedVersions}
|
||||
usedVersion={postgresVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={postgresVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={postgresVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={postgresStatus?.state}
|
||||
/>
|
||||
@@ -256,9 +128,9 @@ export default function OverviewProjectHealth() {
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices['hasura-storage'].displayName}
|
||||
serviceKey="hasura-storage"
|
||||
usedVersion={configuredVersionsData?.config?.storage?.version ?? ''}
|
||||
recommendedVersionMismatch={isStorageVersionMismatch}
|
||||
recommendedVersions={storageRecommendedVersions}
|
||||
usedVersion={storageVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={storageVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={storageVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={storageStatus?.state}
|
||||
/>
|
||||
@@ -268,16 +140,16 @@ export default function OverviewProjectHealth() {
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.ai.displayName}
|
||||
serviceKey="ai"
|
||||
usedVersion={configuredVersionsData?.config?.ai?.version ?? ''}
|
||||
recommendedVersionMismatch={isAIVersionMismatch}
|
||||
recommendedVersions={aiRecommendedVersions}
|
||||
usedVersion={aiVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={aiVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={aiVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={aiStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const runServices = Object.values(otherServicesStatus).filter((service) =>
|
||||
service.name.startsWith('run-'),
|
||||
const runServices = Object.values(runStatus).filter((service) =>
|
||||
service?.name?.startsWith('run-'),
|
||||
);
|
||||
|
||||
const runServicesStates = runServices.map((service) => service.state);
|
||||
@@ -293,32 +165,32 @@ export default function OverviewProjectHealth() {
|
||||
<ProjectHealthCard
|
||||
icon={<UserIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={authTooltipElem}
|
||||
isVersionMismatch={isAuthVersionMismatch}
|
||||
isVersionMismatch={authVersionInfo?.isVersionMismatch}
|
||||
state={authStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<DatabaseIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={postgresTooltipElem}
|
||||
isVersionMismatch={isPostgresVersionMismatch}
|
||||
isVersionMismatch={postgresVersionInfo?.isVersionMismatch}
|
||||
state={postgresStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<StorageIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={storageTooltipElem}
|
||||
isVersionMismatch={isStorageVersionMismatch}
|
||||
isVersionMismatch={storageVersionInfo?.isVersionMismatch}
|
||||
state={storageStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<HasuraIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={hasuraTooltipElem}
|
||||
isVersionMismatch={isHasuraVersionMismatch}
|
||||
isVersionMismatch={hasuraVersionInfo?.isVersionMismatch}
|
||||
state={hasuraStatus?.state}
|
||||
/>
|
||||
{isAIServiceEnabled && (
|
||||
{isAIEnabled && (
|
||||
<ProjectHealthCard
|
||||
icon={<AIIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={aiTooltipElem}
|
||||
isVersionMismatch={isAIVersionMismatch}
|
||||
isVersionMismatch={aiVersionInfo?.isVersionMismatch}
|
||||
state={aiStatus?.state}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,220 +1,33 @@
|
||||
import { CodeBlock } from '@/components/presentational/CodeBlock';
|
||||
import { Accordion } from '@/components/ui/v2/Accordion';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { ServicesOutlinedIcon } from '@/components/ui/v2/icons/ServicesOutlinedIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useServiceStatus } from '@/features/projects/common/hooks/useServiceStatus';
|
||||
import { ServiceAccordion } from '@/features/projects/overview/components/ServiceAccordion';
|
||||
import {
|
||||
findHighestImportanceState,
|
||||
serviceStateToThemeColor,
|
||||
type baseServices,
|
||||
type ServiceHealthInfo,
|
||||
} from '@/features/projects/overview/health';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
ServiceState,
|
||||
type GetProjectServicesHealthQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import Image from 'next/image';
|
||||
import { type ReactElement } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface ServiceAccordionProps {
|
||||
serviceName: string;
|
||||
serviceHealth: ServiceHealthInfo;
|
||||
replicas: ServiceHealthInfo['replicas'];
|
||||
serviceState: ServiceState;
|
||||
/**
|
||||
* Icon to display on the accordion.
|
||||
*/
|
||||
icon?: string | ReactElement;
|
||||
/**
|
||||
* Label of the icon.
|
||||
*/
|
||||
alt?: string;
|
||||
iconIsComponent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
function ServiceAccordion({
|
||||
serviceName,
|
||||
serviceHealth,
|
||||
replicas,
|
||||
serviceState,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
alt,
|
||||
defaultExpanded = false,
|
||||
}: ServiceAccordionProps) {
|
||||
const replicasLabel = replicas.length === 1 ? 'replica' : 'replicas';
|
||||
|
||||
const serviceInfo = removeTypename(serviceHealth);
|
||||
|
||||
return (
|
||||
<Accordion.Root defaultExpanded={defaultExpanded}>
|
||||
<Accordion.Summary
|
||||
expandIcon={
|
||||
<ChevronDownIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
className="px-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && <Image src={icon} alt={alt} />}
|
||||
<Text
|
||||
sx={{ color: 'text.primary' }}
|
||||
variant="h4"
|
||||
className="font-semibold"
|
||||
>
|
||||
{serviceName}{' '}
|
||||
<Text
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
component="span"
|
||||
className="font-semibold"
|
||||
>
|
||||
({replicas.length} {replicasLabel})
|
||||
</Text>
|
||||
</Text>
|
||||
{serviceState === ServiceState.Running ? (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="flex h-2 w-2 items-center justify-center rounded-full"
|
||||
>
|
||||
<CheckIcon className="h-3/4 w-3/4 stroke-2 text-white" />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="h-2 w-2 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Summary>
|
||||
<Accordion.Details>
|
||||
<CodeBlock copyToClipboardToastTitle={`${serviceName} status`}>
|
||||
{JSON.stringify(serviceInfo, null, 2)}
|
||||
</CodeBlock>
|
||||
</Accordion.Details>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunServicesAccordionProps {
|
||||
servicesHealth: Array<ServiceHealthInfo>;
|
||||
serviceStates: ServiceState[];
|
||||
/**
|
||||
* Icon to display on the accordion.
|
||||
*/
|
||||
icon?: string | ReactElement;
|
||||
/**
|
||||
* Label of the icon.
|
||||
*/
|
||||
alt?: string;
|
||||
iconIsComponent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
function RunServicesAccordion({
|
||||
serviceStates,
|
||||
servicesHealth,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
defaultExpanded = false,
|
||||
alt,
|
||||
}: RunServicesAccordionProps) {
|
||||
const globalState = findHighestImportanceState(serviceStates);
|
||||
|
||||
const serviceInfo = removeTypename(servicesHealth);
|
||||
|
||||
return (
|
||||
<Accordion.Root defaultExpanded={defaultExpanded}>
|
||||
<Accordion.Summary
|
||||
expandIcon={
|
||||
<ChevronDownIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
className="px-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && <Image src={icon} alt={alt} />}
|
||||
<Text
|
||||
sx={{ color: 'text.primary' }}
|
||||
variant="h4"
|
||||
className="font-semibold"
|
||||
>
|
||||
Run
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(globalState),
|
||||
}}
|
||||
className="h-2 w-2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Summary>
|
||||
<Accordion.Details>
|
||||
<CodeBlock copyToClipboardToastTitle="Run services status">
|
||||
{JSON.stringify(serviceInfo, null, 2)}
|
||||
</CodeBlock>
|
||||
</Accordion.Details>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OverviewProjectHealthModalProps {
|
||||
servicesHealth?: GetProjectServicesHealthQuery;
|
||||
defaultExpanded?: keyof typeof baseServices | 'run';
|
||||
}
|
||||
|
||||
export default function OverviewProjectHealthModal({
|
||||
servicesHealth,
|
||||
defaultExpanded,
|
||||
}: OverviewProjectHealthModalProps) {
|
||||
const serviceMap: { [key: string]: ServiceHealthInfo | undefined } = {};
|
||||
servicesHealth.getProjectStatus.services.forEach((service) => {
|
||||
serviceMap[service.name] = service;
|
||||
const { auth, storage, postgres, hasura, ai, run } = useServiceStatus({
|
||||
fetchPolicy: 'cache-only',
|
||||
shouldPoll: false,
|
||||
});
|
||||
const {
|
||||
'hasura-auth': auth,
|
||||
'hasura-storage': storage,
|
||||
postgres,
|
||||
hasura,
|
||||
ai,
|
||||
...otherServices
|
||||
} = serviceMap;
|
||||
|
||||
const runServices = Object.values(otherServices).filter((service) =>
|
||||
const runServices = Object.values(run).filter((service) =>
|
||||
service.name.startsWith('run-'),
|
||||
);
|
||||
|
||||
@@ -225,6 +38,24 @@ export default function OverviewProjectHealthModal({
|
||||
const isAIExpandedByDefault = defaultExpanded === 'ai';
|
||||
const isRunExpandedByDefault = defaultExpanded === 'run';
|
||||
|
||||
const getServiceInfo = (service) => {
|
||||
const info = removeTypename(service);
|
||||
return JSON.stringify(info, null, 2);
|
||||
};
|
||||
|
||||
const serviceInfo = {
|
||||
auth: getServiceInfo(auth),
|
||||
storage: getServiceInfo(storage),
|
||||
postgres: getServiceInfo(postgres),
|
||||
hasura: getServiceInfo(hasura),
|
||||
ai: getServiceInfo(ai),
|
||||
run: getServiceInfo(Object.values(runServices)),
|
||||
};
|
||||
|
||||
const runServicesState = findHighestImportanceState(
|
||||
Object.values(runServices).map((service) => service.state),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg pt-2 text-left')}>
|
||||
<Box
|
||||
@@ -237,8 +68,8 @@ export default function OverviewProjectHealthModal({
|
||||
<ServiceAccordion
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
serviceName="Auth"
|
||||
serviceHealth={auth}
|
||||
replicas={auth?.replicas}
|
||||
serviceInfo={serviceInfo.auth}
|
||||
replicaCount={auth?.replicas?.length}
|
||||
serviceState={auth?.state}
|
||||
defaultExpanded={isAuthExpandedByDefault}
|
||||
/>
|
||||
@@ -246,8 +77,8 @@ export default function OverviewProjectHealthModal({
|
||||
<ServiceAccordion
|
||||
icon={<DatabaseIcon className="h-4 w-4" />}
|
||||
serviceName="Postgres"
|
||||
serviceHealth={postgres}
|
||||
replicas={postgres?.replicas}
|
||||
serviceInfo={serviceInfo.postgres}
|
||||
replicaCount={postgres?.replicas?.length}
|
||||
serviceState={postgres?.state}
|
||||
defaultExpanded={isPostgresExpandedByDefault}
|
||||
/>
|
||||
@@ -255,8 +86,8 @@ export default function OverviewProjectHealthModal({
|
||||
<ServiceAccordion
|
||||
icon={<StorageIcon className="h-4 w-4" />}
|
||||
serviceName="Storage"
|
||||
serviceHealth={storage}
|
||||
replicas={storage?.replicas}
|
||||
serviceInfo={serviceInfo.storage}
|
||||
replicaCount={storage?.replicas?.length}
|
||||
serviceState={storage?.state}
|
||||
defaultExpanded={isStorageExpandedByDefault}
|
||||
/>
|
||||
@@ -264,8 +95,8 @@ export default function OverviewProjectHealthModal({
|
||||
<ServiceAccordion
|
||||
icon={<HasuraIcon className="h-4 w-4" />}
|
||||
serviceName="Hasura"
|
||||
serviceHealth={hasura}
|
||||
replicas={hasura?.replicas}
|
||||
serviceInfo={serviceInfo.hasura}
|
||||
replicaCount={hasura?.replicas?.length}
|
||||
serviceState={hasura?.state}
|
||||
defaultExpanded={isHasuraExpandedByDefault}
|
||||
/>
|
||||
@@ -275,22 +106,22 @@ export default function OverviewProjectHealthModal({
|
||||
<ServiceAccordion
|
||||
icon={<AIIcon className="h-4 w-4" />}
|
||||
serviceName="AI"
|
||||
serviceHealth={ai}
|
||||
replicas={ai.replicas}
|
||||
serviceState={ai.state}
|
||||
serviceInfo={serviceInfo.ai}
|
||||
replicaCount={ai?.replicas?.length}
|
||||
serviceState={ai?.state}
|
||||
defaultExpanded={isAIExpandedByDefault}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{Object.values(runServices).length > 0 ? (
|
||||
{runServices && Object.values(runServices).length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<RunServicesAccordion
|
||||
servicesHealth={Object.values(runServices)}
|
||||
<ServiceAccordion
|
||||
icon={<ServicesOutlinedIcon className="h-4 w-4" />}
|
||||
serviceStates={Object.values(runServices).map(
|
||||
(service) => service.state,
|
||||
)}
|
||||
serviceName="Run"
|
||||
serviceInfo={serviceInfo.run}
|
||||
replicaCount={0}
|
||||
serviceState={runServicesState}
|
||||
defaultExpanded={isRunExpandedByDefault}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/v2/Badge';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ExclamationFilledIcon } from '@/components/ui/v2/icons/ExclamationFilledIcon';
|
||||
import { QuestionMarkIcon } from '@/components/ui/v2/icons/QuestionMarkIcon';
|
||||
|
||||
export interface ProjectHealthBadgeProps extends BadgeProps {
|
||||
badgeVariant?: 'standard' | 'dot';
|
||||
badgeColor?: 'success' | 'error' | 'warning' | 'secondary';
|
||||
unknownState?: boolean;
|
||||
showExclamation?: boolean;
|
||||
showCheckIcon?: boolean;
|
||||
isLoading?: boolean;
|
||||
blink?: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectHealthBadge({
|
||||
badgeColor,
|
||||
badgeVariant,
|
||||
showExclamation,
|
||||
showCheckIcon,
|
||||
unknownState,
|
||||
blink,
|
||||
children,
|
||||
...props
|
||||
}: ProjectHealthBadgeProps) {
|
||||
let innerBadgeContent = null;
|
||||
if (unknownState) {
|
||||
innerBadgeContent = (
|
||||
<QuestionMarkIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
);
|
||||
} else if (showCheckIcon) {
|
||||
innerBadgeContent = (
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!badgeColor) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
if (showExclamation) {
|
||||
return (
|
||||
<Badge
|
||||
variant="standard"
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
badgeContent={
|
||||
<ExclamationFilledIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.600',
|
||||
}}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={innerBadgeContent}
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'text.primary',
|
||||
}}
|
||||
componentsProps={{
|
||||
badge: {
|
||||
className: blink ? 'animate-pulse' : '',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={innerBadgeContent}
|
||||
componentsProps={{
|
||||
badge: {
|
||||
className: blink ? 'animate-pulse' : '',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectHealthBadge } from './ProjectHealthBadge';
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/v2/Badge';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ExclamationFilledIcon } from '@/components/ui/v2/icons/ExclamationFilledIcon';
|
||||
import { Tooltip, tooltipClasses } from '@/components/ui/v2/Tooltip';
|
||||
import { ProjectHealthBadge } from '@/features/projects/overview/components/ProjectHealthBadge';
|
||||
import { serviceStateToBadgeColor } from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
import type { ImageProps } from 'next/image';
|
||||
@@ -11,92 +9,6 @@ import Image from 'next/image';
|
||||
import type { ReactElement } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface HealthBadgeProps extends BadgeProps {
|
||||
badgeVariant?: 'standard' | 'dot';
|
||||
badgeColor?: 'success' | 'error' | 'warning';
|
||||
showExclamation?: boolean;
|
||||
showCheckIcon?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function HealthBadge({
|
||||
badgeColor,
|
||||
badgeVariant,
|
||||
showExclamation,
|
||||
showCheckIcon,
|
||||
children,
|
||||
...props
|
||||
}: HealthBadgeProps) {
|
||||
if (!badgeColor) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
if (showExclamation) {
|
||||
return (
|
||||
<Badge
|
||||
variant="standard"
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
badgeContent={
|
||||
<ExclamationFilledIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.600',
|
||||
}}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={
|
||||
showCheckIcon ? (
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'text.primary',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={
|
||||
showCheckIcon ? (
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProjectHealthCardProps extends BoxProps {
|
||||
/**
|
||||
* Label of the card icon.
|
||||
@@ -159,8 +71,13 @@ export default function ProjectHealthCard({
|
||||
...props
|
||||
}: ProjectHealthCardProps) {
|
||||
const badgeColor = serviceStateToBadgeColor.get(state);
|
||||
const badgeVariant = state === ServiceState.Running ? 'standard' : 'dot';
|
||||
const unknownState = state === undefined;
|
||||
let badgeVariant: 'dot' | 'standard' = 'dot';
|
||||
if (state === ServiceState.Running || unknownState) {
|
||||
badgeVariant = 'standard';
|
||||
}
|
||||
const showCheckIcon = state === ServiceState.Running;
|
||||
const shouldBlink = state === ServiceState.Updating;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -186,11 +103,13 @@ export default function ProjectHealthCard({
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-flow-col items-center justify-center">
|
||||
<HealthBadge
|
||||
<ProjectHealthBadge
|
||||
badgeColor={!isLoading ? badgeColor : undefined}
|
||||
badgeVariant={badgeVariant}
|
||||
showCheckIcon={showCheckIcon}
|
||||
showExclamation={isVersionMismatch}
|
||||
unknownState={unknownState}
|
||||
blink={shouldBlink}
|
||||
>
|
||||
{iconIsComponent
|
||||
? icon
|
||||
@@ -203,7 +122,7 @@ export default function ProjectHealthCard({
|
||||
{...slotProps.imgIcon}
|
||||
/>
|
||||
)}
|
||||
</HealthBadge>
|
||||
</ProjectHealthBadge>
|
||||
</div>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import {
|
||||
serviceStateToThemeColor,
|
||||
type baseServices,
|
||||
type ServiceHealthInfo,
|
||||
} from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/generated/graphql';
|
||||
|
||||
export interface RunStatusTooltipProps {
|
||||
servicesStatusInfo?: Array<ServiceHealthInfo>;
|
||||
openHealthModal?: (
|
||||
defaultExpanded?: keyof typeof baseServices | 'run',
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function RunStatusTooltip({
|
||||
servicesStatusInfo,
|
||||
openHealthModal,
|
||||
}: RunStatusTooltipProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 px-2 py-3">
|
||||
<ol className="m-0 flex flex-col gap-3">
|
||||
{servicesStatusInfo.map((service) => (
|
||||
<li
|
||||
key={service.name}
|
||||
className="flex flex-row items-center gap-4 text-ellipsis text-nowrap leading-5"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(service.state),
|
||||
}}
|
||||
className={`h-3 w-3 flex-shrink-0 rounded-full ${
|
||||
service.state === ServiceState.Updating ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<Text
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
{service.name}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button variant="outlined" onClick={() => openHealthModal('run')}>
|
||||
View state
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunStatusTooltip } from './RunStatusTooltip';
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CodeBlock } from '@/components/presentational/CodeBlock';
|
||||
import { Accordion } from '@/components/ui/v2/Accordion';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AccordionHealthBadge } from '@/features/projects/overview/components/AccordionHealthBadge';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
import Image from 'next/image';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
export interface ServiceAccordionProps {
|
||||
serviceName: string;
|
||||
serviceInfo: string;
|
||||
replicaCount: number;
|
||||
serviceState: ServiceState;
|
||||
/**
|
||||
* Icon to display on the accordion.
|
||||
*/
|
||||
icon?: string | ReactElement;
|
||||
/**
|
||||
* Label of the icon.
|
||||
*/
|
||||
alt?: string;
|
||||
iconIsComponent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export default function ServiceAccordion({
|
||||
serviceName,
|
||||
serviceInfo,
|
||||
replicaCount,
|
||||
serviceState,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
alt,
|
||||
defaultExpanded = false,
|
||||
}: ServiceAccordionProps) {
|
||||
const unknownState = serviceState === undefined;
|
||||
|
||||
const replicasLabel = replicaCount === 1 ? 'replica' : 'replicas';
|
||||
|
||||
const blink = serviceState === ServiceState.Updating;
|
||||
|
||||
return (
|
||||
<Accordion.Root defaultExpanded={defaultExpanded}>
|
||||
<Accordion.Summary
|
||||
expandIcon={
|
||||
<ChevronDownIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
className="px-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && <Image src={icon} alt={alt} />}
|
||||
<Text
|
||||
sx={{ color: 'text.primary' }}
|
||||
variant="h4"
|
||||
className="font-semibold"
|
||||
>
|
||||
{serviceName}{' '}
|
||||
{!unknownState && replicaCount && replicasLabel ? (
|
||||
<Text
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
component="span"
|
||||
className="font-semibold"
|
||||
>
|
||||
({replicaCount} {replicasLabel})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<AccordionHealthBadge
|
||||
serviceState={serviceState}
|
||||
unknownState={unknownState}
|
||||
blink={blink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Summary>
|
||||
<Accordion.Details>
|
||||
<CodeBlock copyToClipboardToastTitle={`${serviceName} status`}>
|
||||
{serviceInfo}
|
||||
</CodeBlock>
|
||||
</Accordion.Details>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServiceAccordion } from './ServiceAccordion';
|
||||
@@ -35,19 +35,19 @@ export const serviceStateToThemeColor = new Map<ServiceState, string>([
|
||||
[ServiceState.UpdateError, 'error.main'],
|
||||
[ServiceState.Updating, 'warning.dark'],
|
||||
[ServiceState.None, 'error.main'],
|
||||
[undefined, 'error.main'],
|
||||
[undefined, 'grey.500'],
|
||||
]);
|
||||
|
||||
export const serviceStateToBadgeColor = new Map<
|
||||
ServiceState,
|
||||
'success' | 'error' | 'warning'
|
||||
'success' | 'error' | 'warning' | 'secondary'
|
||||
>([
|
||||
[ServiceState.Running, 'success'],
|
||||
[ServiceState.Error, 'error'],
|
||||
[ServiceState.UpdateError, 'error'],
|
||||
[ServiceState.Updating, 'warning'],
|
||||
[ServiceState.None, 'error'],
|
||||
[undefined, 'error'],
|
||||
[undefined, 'secondary'], // secondary is used for unknown states
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user