Compare commits
214 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172fd8dfed | ||
|
|
a99ca90279 | ||
|
|
5892fd7f01 | ||
|
|
0344cc9a6d | ||
|
|
2697e28cf2 | ||
|
|
54231b119f | ||
|
|
7c977e7143 | ||
|
|
7c3019389e | ||
|
|
46f028b9fd | ||
|
|
683e85b89f | ||
|
|
b0f27c908d | ||
|
|
5f2618e183 | ||
|
|
29037147f2 | ||
|
|
95b630a621 | ||
|
|
0fdfd8ad81 | ||
|
|
174b4165b3 | ||
|
|
04257bc09c | ||
|
|
184c341f05 | ||
|
|
52fdce291f | ||
|
|
c43ff40e1f | ||
|
|
4ec2f8f186 | ||
|
|
7ece80a39e | ||
|
|
1327351e1b | ||
|
|
1fbdf630a5 | ||
|
|
cef11677f4 | ||
|
|
af33c21d10 | ||
|
|
1b01d56e82 | ||
|
|
229acb1d60 | ||
|
|
b4dccd4496 | ||
|
|
31683fa926 | ||
|
|
e9d5d0a53e | ||
|
|
4e0b132d20 | ||
|
|
425476759f | ||
|
|
04784d880b | ||
|
|
130131c488 | ||
|
|
f0da84bbec | ||
|
|
5efa43aa2e | ||
|
|
2497194dcc | ||
|
|
5733162ed6 | ||
|
|
ab106c9492 | ||
|
|
4d2aac807c | ||
|
|
a659760724 | ||
|
|
13086bcae3 | ||
|
|
86459468be | ||
|
|
34cec77ceb | ||
|
|
abfb42651a | ||
|
|
ec584181cc | ||
|
|
70b31358bc | ||
|
|
521f418f8c | ||
|
|
8851416e7a | ||
|
|
f98f5a4bca | ||
|
|
650a605b61 | ||
|
|
422e1bbeae | ||
|
|
367e86abd2 | ||
|
|
7e172d6352 | ||
|
|
e786a6fa84 | ||
|
|
f899f4000d | ||
|
|
ecd27f34d6 | ||
|
|
9f488d2739 | ||
|
|
fac066c0cd | ||
|
|
fdc56e9611 | ||
|
|
7b11f343ac | ||
|
|
04d39bef90 | ||
|
|
83e21f879f | ||
|
|
8e26cdb5ed | ||
|
|
4dc1a5ded3 | ||
|
|
b3f6c732dd | ||
|
|
a63342d0bd | ||
|
|
4913ff7a8b | ||
|
|
99cbbbcbf9 | ||
|
|
3a11b6a8fa | ||
|
|
be4b26c65d | ||
|
|
33df3c842d | ||
|
|
a5bba46b59 | ||
|
|
1358a41dc4 | ||
|
|
2b7cf59159 | ||
|
|
083c65b775 | ||
|
|
1c940469fb | ||
|
|
e2bf1118f9 | ||
|
|
9a1ad43370 | ||
|
|
e2b79b5ece | ||
|
|
c47d47ac9c | ||
|
|
926590acb5 | ||
|
|
90e8843314 | ||
|
|
aa5b360932 | ||
|
|
daa4b8b2ad | ||
|
|
a1c5c97a59 | ||
|
|
b338793d6d | ||
|
|
b1fb4b2400 | ||
|
|
f75e023672 | ||
|
|
8e78c1ff00 | ||
|
|
9cbb0b2986 | ||
|
|
363a3b92e5 | ||
|
|
6a078fc972 | ||
|
|
1091e9674a | ||
|
|
9738108d58 | ||
|
|
65951e1d1d | ||
|
|
b4af994a58 | ||
|
|
c6347e10bc | ||
|
|
278a641bc1 | ||
|
|
3320ddd8c8 | ||
|
|
bc9eff6e41 | ||
|
|
258c608882 | ||
|
|
ae84f269d4 | ||
|
|
0327250b19 | ||
|
|
7f56eabd24 | ||
|
|
be110df83a | ||
|
|
361e648daf | ||
|
|
8a72e20e3d | ||
|
|
125ec390ca | ||
|
|
7cc788a373 | ||
|
|
2a04bc9e5d | ||
|
|
f7c2148ace | ||
|
|
78d35eed09 | ||
|
|
c5ff53c622 | ||
|
|
d21714d169 | ||
|
|
0d16ad41b8 | ||
|
|
82c328eeda | ||
|
|
d991cd8c7e | ||
|
|
e469628ebe | ||
|
|
856bc0a4bb | ||
|
|
9b1fb1ce28 | ||
|
|
a4d16f1835 | ||
|
|
3db8644075 | ||
|
|
7f667f6acb | ||
|
|
685dc6c1e4 | ||
|
|
6f7f2b0a65 | ||
|
|
6d0167b33f | ||
|
|
3ffb60f0ae | ||
|
|
97ced73a3c | ||
|
|
39c86cea25 | ||
|
|
d2d590db7e | ||
|
|
3bdbefc015 | ||
|
|
79081b43c2 | ||
|
|
a4b541f100 | ||
|
|
4523020c33 | ||
|
|
2e2248fd44 | ||
|
|
63358eb80b | ||
|
|
ded674fab6 | ||
|
|
85f2f28902 | ||
|
|
b8e9ad831e | ||
|
|
4e0c5dd1d3 | ||
|
|
b874109c6d | ||
|
|
21b926cc07 | ||
|
|
c35cd47d97 | ||
|
|
8dcd801c7c | ||
|
|
e3199be749 | ||
|
|
284b31e036 | ||
|
|
e7593c7de8 | ||
|
|
e6d862ac1b | ||
|
|
f73672372f | ||
|
|
7f12b98d94 | ||
|
|
d79b66314d | ||
|
|
2a58266592 | ||
|
|
44c2c5467d | ||
|
|
142752cb79 | ||
|
|
b05236a23c | ||
|
|
11a46a0db1 | ||
|
|
cedff501d6 | ||
|
|
7c426dafb2 | ||
|
|
57e7f794f5 | ||
|
|
d4b6cb0acf | ||
|
|
5d0cf8814b | ||
|
|
96cf17bbeb | ||
|
|
ed1a8d458e | ||
|
|
8077495c18 | ||
|
|
b617ec7186 | ||
|
|
bb2da11dd4 | ||
|
|
94fa824e7d | ||
|
|
32d1ee124f | ||
|
|
138bf9eb5a | ||
|
|
d8d9310e0b | ||
|
|
67b2c044b8 | ||
|
|
0b7790ca83 | ||
|
|
55267c680e | ||
|
|
4d856f557f | ||
|
|
64c579cf8c | ||
|
|
eae65c715b | ||
|
|
9e69f9f235 | ||
|
|
8b127fbb62 | ||
|
|
86ba2081ec | ||
|
|
7c2c31082a | ||
|
|
60f705b033 | ||
|
|
ea34635eb2 | ||
|
|
2004687044 | ||
|
|
bd025d43ca | ||
|
|
87a05f7374 | ||
|
|
798f147db7 | ||
|
|
62b7fd2376 | ||
|
|
1ee021b4a3 | ||
|
|
6e61dce297 | ||
|
|
bd744e52dc | ||
|
|
85723d740b | ||
|
|
36e79e7b32 | ||
|
|
f61264b319 | ||
|
|
e84d9d2576 | ||
|
|
ea69d4f0f1 | ||
|
|
212d58bee5 | ||
|
|
c3d6b7beec | ||
|
|
5d5d8ef4f3 | ||
|
|
deb61fe97c | ||
|
|
04d36154b0 | ||
|
|
203cfb10b9 | ||
|
|
9690f871fa | ||
|
|
74a6b93971 | ||
|
|
dd4c0d2430 | ||
|
|
83f2ca5cde | ||
|
|
0c49e757c8 | ||
|
|
e90a9d7696 | ||
|
|
00a06466f5 | ||
|
|
8ca9f76cb2 | ||
|
|
78113dd62a | ||
|
|
adb0ee82c6 | ||
|
|
a41bb6cae6 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -146,7 +146,7 @@ jobs:
|
||||
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
|
||||
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -16,3 +16,6 @@ NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
||||
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>
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 174b4165b: chore: use env variables when running graphql codegen
|
||||
- 7c977e714: chore: change `Allowed Roles` to `Default Allowed Roles`
|
||||
- 46f028b9f: fix: remove hardcoded ai version setting
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- af33c21d1: chore: remove backendUrl deprecation notice and remove all references to `providersUpdated`
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 04784d880: Fix graphite's default version
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5733162ed: feat: add settings and ui for graphite
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e2b79b5ec: chore: remove sharp from deps
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@7.0.1
|
||||
- @nhost/nextjs@2.0.1
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- bc9eff6e4: chore: remove support for using backendUrl when instantiating the Nhost client
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bc9eff6e4]
|
||||
- @nhost/nextjs@2.0.0
|
||||
- @nhost/react-apollo@7.0.0
|
||||
|
||||
## 0.21.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 97ced73a3: fix(dashboard): prevent dashboard from resolving secrets
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ed1a8d458: Update alert message on increasing PostgreSQL's volume capacity
|
||||
- 2e2248fd4: feat(dashboard): add SQL editor
|
||||
|
||||
## 0.20.28
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7c2c31082: feat: add support for users to delete their account
|
||||
- @nhost/react-apollo@6.0.1
|
||||
- @nhost/nextjs@1.13.40
|
||||
|
||||
## 0.20.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16-alpine AS pruner
|
||||
FROM node:18-alpine AS pruner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
@@ -7,7 +7,7 @@ RUN yarn global add turbo@1.10.11
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
FROM node:16-alpine AS builder
|
||||
FROM node:18-alpine AS builder
|
||||
ARG TURBO_TOKEN
|
||||
ARG TURBO_TEAM
|
||||
|
||||
@@ -40,7 +40,7 @@ COPY turbo.json turbo.json
|
||||
COPY config/ config/
|
||||
RUN pnpm build:dashboard
|
||||
|
||||
FROM node:16-alpine AS runner
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
|
||||
@@ -30,7 +30,7 @@ test('should show a sidebar with menu items', async () => {
|
||||
const navLocator = page.getByRole('navigation', { name: /main navigation/i });
|
||||
await expect(navLocator).toBeVisible();
|
||||
await expect(navLocator.getByRole('list').getByRole('listitem')).toHaveCount(
|
||||
12,
|
||||
13,
|
||||
);
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /overview/i }),
|
||||
|
||||
14
dashboard/graphite.graphql.config.yaml
Normal file
14
dashboard/graphite.graphql.config.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
schema:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
generates:
|
||||
src/utils/__generated__/graphite.graphql.ts:
|
||||
documents:
|
||||
- 'src/gql/graphite/**/*.gql'
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
- 'typescript-react-apollo'
|
||||
config:
|
||||
withRefetchFn: true
|
||||
@@ -1,12 +1,13 @@
|
||||
schema:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
- ${CODEGEN_GRAPHQL_URL}:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
x-hasura-admin-secret: ${CODEGEN_HASURA_ADMIN_SECRET}
|
||||
generates:
|
||||
src/utils/__generated__/graphql.ts:
|
||||
documents:
|
||||
- 'src/**/*.graphql'
|
||||
- 'src/**/*.gql'
|
||||
- '!src/gql/graphite/**/*.gql'
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.27",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -10,7 +10,8 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint --max-warnings 0",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"codegen": "DOTENV_CONFIG_PATH=./.env.local graphql-codegen -r dotenv/config --config graphql.config.yaml --errors-only",
|
||||
"codegen-graphite": "graphql-codegen --config graphite.graphql.config.yaml --errors-only",
|
||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
@@ -19,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.10",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/lang-sql": "^6.5.4",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/server": "^11.4.0",
|
||||
@@ -44,6 +45,8 @@
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@tanstack/react-table": "^8.5.30",
|
||||
"@tanstack/react-virtual": "^3.0.0-beta.23",
|
||||
"@uiw/codemirror-theme-github": "^4.21.20",
|
||||
"@uiw/react-codemirror": "^4.21.20",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
@@ -62,6 +65,7 @@
|
||||
"node-pg-format": "^1.3.5",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-children-utilities": "^2.9.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
@@ -69,10 +73,14 @@
|
||||
"react-intersection-observer": "^9.5.2",
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-resizable-layout": "^0.7.2",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
"recoil": "^0.7.7",
|
||||
"recoil-persist": "^5.1.0",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.5",
|
||||
"stripe": "^10.17.0",
|
||||
@@ -100,6 +108,7 @@
|
||||
"@storybook/manager-webpack5": "^6.5.14",
|
||||
"@storybook/react": "^6.5.14",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
@@ -116,8 +125,8 @@
|
||||
"@types/shell-quote": "^1.7.1",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
|
||||
export default function DepricationNotice() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
return (
|
||||
!currentProject?.providersUpdated && (
|
||||
<Alert severity="warning" className="grid place-content-center">
|
||||
<Text color="warning" className="max-w-3xl text-sm">
|
||||
On December 1st the old backend domain will cease to work. You need to
|
||||
make sure your client is instantiated using the subdomain and region
|
||||
and update your oauth2 settings. You can find more information{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://github.com/nhost/nhost/discussions/2303"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ContactUs } from './DepricationNotice';
|
||||
@@ -9,10 +9,11 @@ import { ChangePlanModal } from '@/features/projects/common/components/ChangePla
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
interface UpgradeToProBannerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | ReactNode;
|
||||
}
|
||||
|
||||
export default function UpgradeToProBanner({
|
||||
@@ -25,7 +26,7 @@ export default function UpgradeToProBanner({
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
className="flex flex-col p-4 space-y-4 rounded-md lg:flex-row lg:items-center lg:space-y-0"
|
||||
className="flex flex-col justify-between space-y-4 rounded-md p-4 lg:flex-row lg:items-center lg:space-y-0"
|
||||
>
|
||||
<div className="flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -39,7 +40,11 @@ export default function UpgradeToProBanner({
|
||||
</div>
|
||||
</div>
|
||||
<Text variant="h3">{title}</Text>
|
||||
<Text>{description}</Text>
|
||||
{typeof description === 'string' ? (
|
||||
<Text>{description}</Text>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-2">
|
||||
@@ -76,25 +81,23 @@ export default function UpgradeToProBanner({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium text-center"
|
||||
className="text-center font-medium"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
See all features
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs mx-auto">
|
||||
<Image
|
||||
src="/illustration-unbox.png"
|
||||
width={400}
|
||||
height={260}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/illustration-unbox.png"
|
||||
width={300}
|
||||
height={140}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
46
dashboard/src/components/layout/AILayout/AILayout.tsx
Normal file
46
dashboard/src/components/layout/AILayout/AILayout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AISidebar } from '@/components/layout/AISidebar';
|
||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AILayoutProps extends ProjectLayoutProps {
|
||||
/**
|
||||
* Props passed to the sidebar component.
|
||||
*/
|
||||
sidebarProps?: SettingsSidebarProps;
|
||||
}
|
||||
|
||||
export default function AILayout({
|
||||
children,
|
||||
mainContainerProps: {
|
||||
className: mainContainerClassName,
|
||||
...mainContainerProps
|
||||
} = {},
|
||||
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
|
||||
...props
|
||||
}: AILayoutProps) {
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{
|
||||
className: twMerge('flex h-full', mainContainerClassName),
|
||||
...mainContainerProps,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AISidebar
|
||||
className={twMerge('w-full max-w-sidebar', sidebarClassName)}
|
||||
{...sidebarProps}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/layout/AILayout/index.ts
Normal file
2
dashboard/src/components/layout/AILayout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AILayout';
|
||||
export { default as SettingsLayout } from './AILayout';
|
||||
143
dashboard/src/components/layout/AISidebar/AISidebar.tsx
Normal file
143
dashboard/src/components/layout/AISidebar/AISidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AISidebarProps extends Omit<BoxProps, 'children'> {}
|
||||
|
||||
interface AINavLinkProps extends ListItemButtonProps {
|
||||
/**
|
||||
* Link to navigate to.
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
function AINavLink({ exact = true, href, children, ...props }: AINavLinkProps) {
|
||||
const router = useRouter();
|
||||
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}/ai`;
|
||||
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
|
||||
|
||||
const active = exact
|
||||
? router.asPath === finalUrl
|
||||
: router.asPath.startsWith(finalUrl);
|
||||
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
dense
|
||||
href={finalUrl}
|
||||
component={NavLink}
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Text>{children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function closeSidebarWhenEscapeIsPressed(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}
|
||||
|
||||
return () =>
|
||||
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}, []);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop
|
||||
open={expanded}
|
||||
className="absolute top-0 left-0 bottom-0 right-0 z-[34] md:hidden"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Close sidebar overlay"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pt-2 pb-17 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<nav aria-label="Settings navigation">
|
||||
<List className="grid gap-2">
|
||||
<AINavLink
|
||||
href="/auto-embeddings"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Auto-Embeddings
|
||||
</AINavLink>
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
|
||||
onClick={toggleExpanded}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
src="/assets/table.svg"
|
||||
alt="A monochrome table"
|
||||
/>
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/layout/AISidebar/index.ts
Normal file
2
dashboard/src/components/layout/AISidebar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AISidebar';
|
||||
export { default as AISidebar } from './AISidebar';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
|
||||
@@ -6,14 +7,19 @@ import { LocalAccountMenu } from '@/components/layout/LocalAccountMenu';
|
||||
import { MobileNav } from '@/components/layout/MobileNav';
|
||||
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 { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { DetailedHTMLProps, HTMLProps, PropsWithoutRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface HeaderProps
|
||||
@@ -23,9 +29,14 @@ export interface HeaderProps
|
||||
|
||||
export default function Header({ className, ...props }: HeaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { openDrawer } = useDialog();
|
||||
|
||||
const { currentProject, refetch: refetchProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const isProjectUpdating =
|
||||
currentProject?.appStates[0]?.stateId === ApplicationStatus.Updating;
|
||||
|
||||
@@ -44,6 +55,23 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
};
|
||||
}, [isProjectUpdating, refetchProject]);
|
||||
|
||||
const openDevAssistant = () => {
|
||||
// The dev assistant can be only answer questions related to a particular project
|
||||
if (!currentProject) {
|
||||
toast.error('You need to be inside a project to open the Assistant', {
|
||||
style: getToastStyleProps().style,
|
||||
...getToastStyleProps().error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openDrawer({
|
||||
title: <GraphiteIcon />,
|
||||
component: <DevAssistant />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
@@ -54,7 +82,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-flow-col items-center gap-3 ">
|
||||
<div className="grid grid-flow-col items-center gap-3">
|
||||
<NavLink href="/" className="w-12">
|
||||
<Logo className="mx-auto cursor-pointer" />
|
||||
</NavLink>
|
||||
@@ -69,6 +97,10 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
</div>
|
||||
|
||||
<div className="hidden grid-flow-col items-center gap-2 sm:grid">
|
||||
<Button className="rounded-full" onClick={openDevAssistant}>
|
||||
<GraphiteIcon />
|
||||
</Button>
|
||||
|
||||
{isPlatform && (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
@@ -50,7 +49,6 @@ export default function SettingsLayout({
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<DepricationNotice />
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
|
||||
@@ -208,6 +208,9 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Custom Domains
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink href="/ai" exact={false} onClick={handleSelect}>
|
||||
AI
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function ProvidersUpdatedAlert() {
|
||||
const theme = useTheme();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [confirmed, setConfirmed] = useState(true);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [confirmProvidersUpdated] = useConfirmProvidersUpdatedMutation({
|
||||
variables: { id: currentProject?.id },
|
||||
});
|
||||
|
||||
async function handleSubmitConfirmation() {
|
||||
const confirmProvidersUpdatedPromise = confirmProvidersUpdated();
|
||||
|
||||
await toast.promise(
|
||||
confirmProvidersUpdatedPromise,
|
||||
{
|
||||
loading: 'Confirming...',
|
||||
success: 'Your settings have been updated successfully.',
|
||||
error: 'An error occurred while trying to confirm the message.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
setConfirmed(false);
|
||||
}
|
||||
|
||||
function handleOpenConfirmationDialog() {
|
||||
openAlertDialog({
|
||||
title: 'Confirm all providers updated?',
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Please make sure to update all providers before continuing. Your
|
||||
sign-in flows might break if you don't.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
onPrimaryAction: handleSubmitConfirmation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid items-center grid-flow-row gap-2 p-4 place-items-center lg:grid-flow-col lg:place-content-between"
|
||||
>
|
||||
<div className="grid grid-flow-row gap-1 text-left">
|
||||
<Text className="font-semibold">
|
||||
Please update the Redirect URL for all providers being used
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm+">
|
||||
We are deprecating your project's old DNS name in favor of
|
||||
individual DNS names for each service. Please make sure to update your
|
||||
providers to use the new auth specific URL under <b>Redirect URL</b>{' '}
|
||||
before the 1st of February 2023.{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/discussions/1319"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
Read the discussion here.
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className={
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text-white hover:bg-brown'
|
||||
: 'text-black hover:bg-orange-300'
|
||||
}
|
||||
onClick={handleOpenConfirmationDialog}
|
||||
>
|
||||
I have updated all Redirect URLs
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ProvidersUpdatedAlert } from './ProvidersUpdatedAlert';
|
||||
24
dashboard/src/components/ui/v2/icons/AIIcon/AIIcon.tsx
Normal file
24
dashboard/src/components/ui/v2/icons/AIIcon/AIIcon.tsx
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/src/components/ui/v2/icons/AIIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/AIIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AIIcon } from './AIIcon';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ArrowElbowRightUp(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 6L11 3L14 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12H11V3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ArrowElbowRightUp.displayName = 'NhostArrowElbowRightUp';
|
||||
|
||||
export default ArrowElbowRightUp;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ArrowElbowRightUp } from './ArrowElbowRightUp';
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function EmbeddingsIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="17"
|
||||
height="17"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 17 17"
|
||||
fill="none"
|
||||
aria-label="Embeddings Icon"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.178057 4.04687L4.04687 0.178057C4.28428 -0.0593522 4.6692 -0.0593522 4.90661 0.178057L8.77542 4.04687C9.01283 4.28428 9.01283 4.6692 8.77542 4.90661C8.53801 5.14402 8.15309 5.14402 7.91568 4.90661L5.08466 2.07559L5.08466 12.7664H3.86881L3.86881 2.07559L1.03779 4.90661C0.800384 5.14402 0.415467 5.14402 0.178057 4.90661C-0.0593524 4.6692 -0.0593524 4.28428 0.178057 4.04687Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.9531 8.22458L16.8219 12.0934C17.0594 12.3308 17.0594 12.7157 16.8219 12.9531L12.9531 16.8219C12.7157 17.0594 12.3308 17.0594 12.0934 16.8219C11.856 16.5845 11.856 16.1996 12.0934 15.9622L14.9244 13.1312H4.23357V11.9153H14.9244L12.0934 9.08432C11.856 8.84691 11.856 8.46199 12.0934 8.22458C12.3308 7.98717 12.7157 7.98717 12.9531 8.22458Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
EmbeddingsIcon.displayName = 'NhostEmbeddingsIcon';
|
||||
|
||||
export default EmbeddingsIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EmbeddingsIcon } from './EmbeddingsIcons';
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function GraphiteIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="22"
|
||||
height="25"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 22 25"
|
||||
aria-label="Graphite"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.39873 13.0137C12.2825 13.0137 14.6203 10.7138 14.6203 7.87671C14.6203 5.03963 12.2825 2.73973 9.39873 2.73973C6.51497 2.73973 4.17722 5.03963 4.17722 7.87671C4.17722 10.7138 6.51497 13.0137 9.39873 13.0137ZM9.39873 15.7534C13.8205 15.7534 17.4051 12.2269 17.4051 7.87671C17.4051 3.52652 13.8205 0 9.39873 0C4.97696 0 1.39241 3.52652 1.39241 7.87671C1.39241 12.2269 4.97696 15.7534 9.39873 15.7534Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.78481 15.7534C2.78481 19.3471 5.74597 22.2603 9.39873 22.2603C13.0515 22.2603 16.0127 19.3471 16.0127 15.7534H18.7975C18.7975 20.8602 14.5895 25 9.39873 25C4.20796 25 0 20.8602 0 15.7534H2.78481Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.37975 1.36986C7.37975 0.613309 8.00315 0 8.77215 0H20.6076C21.3766 0 22 0.613309 22 1.36986C22 2.12642 21.3766 2.73973 20.6076 2.73973H8.77215C8.00315 2.73973 7.37975 2.12642 7.37975 1.36986Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
GraphiteIcon.displayName = 'NhostGraphiteIcon';
|
||||
|
||||
export default GraphiteIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GraphiteIcon } from './GraphiteIcon';
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function TerminalIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Trash"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.49851 3.43968L2.93795 2.94141L1.94141 4.06252L2.50196 4.56079L6.37134 8.00024L2.50196 11.4397L1.94141 11.938L2.93795 13.0591L3.49851 12.5608L7.99851 8.56079C8.15863 8.41847 8.25024 8.21446 8.25024 8.00024C8.25024 7.78601 8.15863 7.582 7.99851 7.43968L3.49851 3.43968ZM7.99987 11.2502H7.24987V12.7502H7.99987H13.9999H14.7499V11.2502H13.9999H7.99987Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
TerminalIcon.displayName = 'NhostTerminalIcon';
|
||||
|
||||
export default TerminalIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TerminalIcon } from './TerminalIcon';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useDeleteUserAccountMutation,
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { type ApolloError } from '@apollo/client';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function ConfirmDeleteAccountModal({
|
||||
close,
|
||||
onDelete,
|
||||
}: {
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const user = useUserData();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const userHasProjects =
|
||||
!loading && data?.workspaces.some((workspace) => workspace.projects.length);
|
||||
|
||||
const userData = useUserData();
|
||||
|
||||
const [deleteUserAccount] = useDeleteUserAccountMutation({
|
||||
variables: { id: userData?.id },
|
||||
});
|
||||
|
||||
const onClickConfirm = async () => {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteUserAccount(),
|
||||
{
|
||||
loading: 'Deleting your account...',
|
||||
success: `The account has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting your account. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Account?
|
||||
</Text>
|
||||
|
||||
{userHasProjects && (
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
You still have active projects. Please delete your projects before
|
||||
proceeding with the account deletion.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete my account`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Project #1"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={onClickConfirm}
|
||||
disabled={userHasProjects}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useSignOut();
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const onDelete = async () => {
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
};
|
||||
|
||||
const confirmDeleteAccount = async () => {
|
||||
openDialog({
|
||||
component: (
|
||||
<ConfirmDeleteAccountModal close={closeDialog} onDelete={onDelete} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Delete Account"
|
||||
description="Please proceed with caution as the removal of your Personal Account and its contents from the Nhost platform is irreversible. This action will permanently delete your account and all associated data."
|
||||
className="px-0"
|
||||
slotProps={{
|
||||
submitButton: { className: 'hidden' },
|
||||
footer: { className: 'hidden' },
|
||||
}}
|
||||
>
|
||||
<Box className="grid grid-flow-row border-t-1">
|
||||
<Button
|
||||
color="error"
|
||||
className="mx-4 mt-4 justify-self-end"
|
||||
onClick={confirmDeleteAccount}
|
||||
>
|
||||
Delete Personal Account
|
||||
</Button>
|
||||
</Box>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteAccount } from './DeleteAccount';
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteUserAccount($id: uuid!) {
|
||||
deleteUser(id: $id) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
345
dashboard/src/features/ai/AssistantForm/AssistantForm.tsx
Normal file
345
dashboard/src/features/ai/AssistantForm/AssistantForm.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
||||
import { WebhooksDataSourcesFormSection } from '@/features/ai/AssistantForm/components/WebhooksDataSourcesFormSection';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
description: Yup.string(),
|
||||
instructions: Yup.string().required('The instructions are required'),
|
||||
model: Yup.string().required('The model is required'),
|
||||
graphql: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
query: Yup.string().required(),
|
||||
arguments: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
type: Yup.string().required(),
|
||||
required: Yup.bool().required(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
webhooks: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
URL: Yup.string().required(),
|
||||
arguments: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
description: Yup.string().required(),
|
||||
type: Yup.string().required(),
|
||||
required: Yup.bool().required(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface AssistantFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
||||
*/
|
||||
assistantId?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AssistantFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the submit is successful.
|
||||
*/
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
}
|
||||
|
||||
export default function AssistantForm({
|
||||
assistantId,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: AssistantFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [insertAssistantMutation] = useInsertAssistantMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const [updateAssistantMutation] = useUpdateAssistantMutation({ client });
|
||||
|
||||
const form = useForm<AssistantFormValues>({
|
||||
defaultValues: initialData,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
) => {
|
||||
// remove any __typename from the form values
|
||||
const payload = removeTypename(values);
|
||||
|
||||
if (values.webhooks.length === 0) {
|
||||
delete payload.webhooks;
|
||||
}
|
||||
|
||||
if (values.graphql.length === 0) {
|
||||
delete payload.graphql;
|
||||
}
|
||||
|
||||
// remove assistantId because the update mutation fails otherwise
|
||||
delete payload.assistantID;
|
||||
|
||||
// If the assistantId is set then we do an update
|
||||
if (assistantId) {
|
||||
await updateAssistantMutation({
|
||||
variables: {
|
||||
id: assistantId,
|
||||
data: payload,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await insertAssistantMutation({
|
||||
variables: {
|
||||
data: {
|
||||
...values,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
) => {
|
||||
try {
|
||||
await toast.promise(
|
||||
createOrUpdateAutoEmbeddings(values),
|
||||
{
|
||||
loading: 'Configuring the Assistant...',
|
||||
success: `The Assistant has been configured successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while configuring the Assistant. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden border-t"
|
||||
>
|
||||
<div className="flex flex-1 flex-col space-y-4 overflow-auto p-4">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Name of the assistant">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('description')}
|
||||
id="description"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Description</Text>
|
||||
<Tooltip title={<span>Description of the assistant</span>}>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.description}
|
||||
helperText={errors?.description?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('instructions')}
|
||||
id="instructions"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Instructions</Text>
|
||||
<Tooltip title="Instructions for the assistant. This is used to instruct the AI assistant on how to behave and respond to the user">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.instructions}
|
||||
helperText={errors?.instructions?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('model')}
|
||||
id="model"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Model</Text>
|
||||
<Tooltip title="Model to use for the assistant.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.model}
|
||||
helperText={errors?.model?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<GraphqlDataSourcesFormSection />
|
||||
<WebhooksDataSourcesFormSection />
|
||||
</div>
|
||||
|
||||
<Box className="flex w-full flex-row justify-between rounded border-t p-4">
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={assistantId ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{assistantId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { ControlledSwitch } from '@/components/form/ControlledSwitch';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { type AssistantFormValues } from '@/features/ai/AssistantForm/AssistantForm';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
interface ArgumentsFormSectionProps {
|
||||
nestedField: string;
|
||||
nestIndex: number;
|
||||
}
|
||||
|
||||
export default function ArgumentsFormSection({
|
||||
nestedField,
|
||||
nestIndex,
|
||||
}: ArgumentsFormSectionProps) {
|
||||
const form = useFormContext<AssistantFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: `${nestedField}.${nestIndex}.arguments`,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4">
|
||||
<div className="flex flex-row items-center justify-between ">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Arguments
|
||||
</Text>
|
||||
<Tooltip title={<span>Arguments</span>}>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button variant="borderless" onClick={() => append({})}>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box
|
||||
key={field.id}
|
||||
className="flex flex-col space-y-20 rounded border-1 p-4"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
<Input
|
||||
// We're putting ts-ignore here so we could use the same components for both graphql and webhooks
|
||||
// by passing the nestedField = 'graphql' or nestedField = 'webhooks'
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.name`,
|
||||
)}
|
||||
id={`${field.id}-name`}
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={
|
||||
!!errors?.[nestedField]?.[nestIndex]?.arguments[index].name
|
||||
}
|
||||
helperText={
|
||||
errors?.[nestedField]?.[nestIndex]?.arguments[index]?.name
|
||||
?.message
|
||||
}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.description`,
|
||||
)}
|
||||
id={`${field.id}-description`}
|
||||
placeholder="Description"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={
|
||||
!!errors?.[nestedField]?.[nestIndex]?.arguments[index]
|
||||
.description
|
||||
}
|
||||
helperText={
|
||||
errors?.[nestedField]?.[nestIndex]?.arguments[index]
|
||||
?.description?.message
|
||||
}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Box className="w-full">
|
||||
<ControlledSelect
|
||||
fullWidth
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.type`,
|
||||
)}
|
||||
id={`${field.id}-type`}
|
||||
placeholder="Select argument type"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{[
|
||||
'string',
|
||||
'number',
|
||||
'integer',
|
||||
'object',
|
||||
'array',
|
||||
'boolean',
|
||||
]?.map((argumentType) => (
|
||||
<Option key={argumentType} value={argumentType}>
|
||||
{argumentType}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledSelect>
|
||||
</Box>
|
||||
<ControlledSwitch
|
||||
{...register(
|
||||
// @ts-ignore
|
||||
`${nestedField}.${nestIndex}.arguments.${index}.required`,
|
||||
)}
|
||||
disabled={false}
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Required
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="h-10 self-end"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ArgumentsFormSection } from './ArgumentsFormSection';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { type AssistantFormValues } from '@/features/ai/AssistantForm/AssistantForm';
|
||||
import { ArgumentsFormSection } from '@/features/ai/AssistantForm/components/ArgumentsFormSection';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function GraphqlDataSourcesFormSection() {
|
||||
const form = useFormContext<AssistantFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'graphql',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between p-4 pb-0">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
GraphQL
|
||||
</Text>
|
||||
<Tooltip title="GraphQL data sources and tools. Run against the project's GraphQL API">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() =>
|
||||
append({
|
||||
name: '',
|
||||
description: '',
|
||||
query: '',
|
||||
arguments: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box key={field.id} className="flex flex-col space-y-4">
|
||||
<Box className="flex w-full flex-col space-y-4 p-4 pt-0">
|
||||
<Input
|
||||
{...register(`graphql.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.graphql?.at(index)?.name}
|
||||
helperText={errors?.graphql?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`graphql.${index}.description`)}
|
||||
id={`${field.id}-description`}
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.graphql?.at(index)?.description}
|
||||
helperText={errors?.graphql?.at(index)?.description?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`graphql.${index}.query`)}
|
||||
id={`${field.id}-query`}
|
||||
label="Query"
|
||||
placeholder="Query"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.graphql?.at(index)?.query}
|
||||
helperText={errors?.graphql?.at(index)?.query?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ArgumentsFormSection nestedField="graphql" nestIndex={index} />
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="h-10 self-end"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{index < fields.length - 1 && (
|
||||
<Divider className="h-px" sx={{ background: 'grey.200' }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GraphqlDataSourcesFormSection } from './GraphqlDataSourcesFormSection';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { type AssistantFormValues } from '@/features/ai/AssistantForm/AssistantForm';
|
||||
import { ArgumentsFormSection } from '@/features/ai/AssistantForm/components/ArgumentsFormSection';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function WebhooksDataSourcesFormSection() {
|
||||
const form = useFormContext<AssistantFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'webhooks',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between p-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Webhooks
|
||||
</Text>
|
||||
<Tooltip title="Webhook data sources and tools">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() =>
|
||||
append({
|
||||
name: '',
|
||||
description: '',
|
||||
URL: '',
|
||||
arguments: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box key={field.id} className="flex flex-col space-y-4">
|
||||
<Box className="flex w-full flex-col space-y-4 p-4 pt-0">
|
||||
<Input
|
||||
{...register(`webhooks.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.webhooks?.at(index)?.name}
|
||||
helperText={errors?.webhooks?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`webhooks.${index}.description`)}
|
||||
id={`${field.id}-description`}
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.webhooks?.at(index)?.description}
|
||||
helperText={errors?.webhooks?.at(index)?.description?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`webhooks.${index}.URL`)}
|
||||
id={`${field.id}-URL`}
|
||||
label="URL"
|
||||
placeholder="URL"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.webhooks?.at(index)?.URL}
|
||||
helperText={errors?.webhooks?.at(index)?.URL?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[22px]',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ArgumentsFormSection nestedField="webhooks" nestIndex={index} />
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="h-10 self-end"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{index < fields.length - 1 && (
|
||||
<Divider className="h-px" sx={{ background: 'grey.200' }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WebhooksDataSourcesFormSection } from './WebhooksDataSourcesFormSection';
|
||||
1
dashboard/src/features/ai/AssistantForm/index.ts
Normal file
1
dashboard/src/features/ai/AssistantForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AssistantForm } from './AssistantForm';
|
||||
158
dashboard/src/features/ai/AssistantsList/AssistantsList.tsx
Normal file
158
dashboard/src/features/ai/AssistantsList/AssistantsList.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AssistantForm } from '@/features/ai/AssistantForm';
|
||||
import { DeleteAssistantModal } from '@/features/ai/DeleteAssistantModal';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
||||
|
||||
interface AssistantsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
*/
|
||||
assistants: Assistant[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function AssistantsList({
|
||||
assistants,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: AssistantsListProps) {
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewAssistant = async (assistant: Assistant) => {
|
||||
openDrawer({
|
||||
title: `Edit ${assistant?.name ?? 'unset'}`,
|
||||
component: (
|
||||
<AssistantForm
|
||||
assistantId={assistant.assistantID}
|
||||
initialData={{
|
||||
...assistant,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAssistant = async (assistant: Assistant) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteAssistantModal
|
||||
assistant={assistant}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{assistants.map((assistant) => (
|
||||
<Box
|
||||
key={assistant.assistantID}
|
||||
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||
sx={{
|
||||
[`&:hover`]: {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => viewAssistant(assistant)}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<span className="text-3xl">🤖</span>
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{assistant?.name ?? 'unset'}
|
||||
</Text>
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{assistant.assistantID}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
copy(assistant.assistantID, 'Assistant Id');
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
aria-label="More options"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-auto' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => viewAssistant(assistant)}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Text className="font-medium">View {assistant?.name}</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteAssistant(assistant)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete {assistant?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/AssistantsList/index.ts
Normal file
1
dashboard/src/features/ai/AssistantsList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AssistantsList } from './AssistantsList';
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import {
|
||||
useInsertGraphiteAutoEmbeddingsConfigurationMutation,
|
||||
useUpdateGraphiteAutoEmbeddingsConfigurationMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
schemaName: Yup.string().required('The schema is required'),
|
||||
tableName: Yup.string().required('The table is required'),
|
||||
columnName: Yup.string().required('The column is required'),
|
||||
query: Yup.string(),
|
||||
mutation: Yup.string(),
|
||||
});
|
||||
|
||||
export type AutoEmbeddingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface AutoEmbeddingsFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
||||
*/
|
||||
autoEmbeddingsId?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AutoEmbeddingsFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the submit is successful.
|
||||
*/
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
}
|
||||
|
||||
export default function AutoEmbeddingsForm({
|
||||
autoEmbeddingsId,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: AutoEmbeddingsFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [insertGraphiteAutoEmbeddingsConfiguration] =
|
||||
useInsertGraphiteAutoEmbeddingsConfigurationMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const [updateGraphiteAutoEmbeddingsConfiguration] =
|
||||
useUpdateGraphiteAutoEmbeddingsConfigurationMutation({ client });
|
||||
|
||||
const form = useForm<AutoEmbeddingsFormValues>({
|
||||
defaultValues: initialData,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: AutoEmbeddingsFormValues,
|
||||
) => {
|
||||
// If the autoEmbeddingsId is set then we do an update
|
||||
if (autoEmbeddingsId) {
|
||||
await updateGraphiteAutoEmbeddingsConfiguration({
|
||||
variables: {
|
||||
id: autoEmbeddingsId,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await insertGraphiteAutoEmbeddingsConfiguration({
|
||||
variables: values,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: AutoEmbeddingsFormValues) => {
|
||||
try {
|
||||
await toast.promise(
|
||||
createOrUpdateAutoEmbeddings(values),
|
||||
{
|
||||
loading: 'Configuring the Auto-Embeddings...',
|
||||
success: `The Auto-Embeddings has been configured successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while configuring the Auto-Embeddings. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col gap-4 overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-1 flex-col space-y-6 overflow-auto px-6">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Name of the Auto-Embeddings">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
{...register('schemaName')}
|
||||
id="schemaName"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Schema</Text>
|
||||
<Tooltip title={<span>Schema where the table belongs to</span>}>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.schemaName}
|
||||
helperText={errors?.schemaName?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register('tableName')}
|
||||
id="tableName"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Table</Text>
|
||||
<Tooltip title="Table Name">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.tableName}
|
||||
helperText={errors?.tableName?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register('columnName')}
|
||||
id="columnName"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Column</Text>
|
||||
<Tooltip title="Column name">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.columnName}
|
||||
helperText={errors?.columnName?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register('query')}
|
||||
id="query"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Query</Text>
|
||||
<Tooltip title="Query">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.query}
|
||||
helperText={errors?.query?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
rows={6}
|
||||
/>
|
||||
<Input
|
||||
{...register('mutation')}
|
||||
id="mutation"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Mutation</Text>
|
||||
<Tooltip title="Mutation">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.mutation}
|
||||
helperText={errors?.mutation?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
multiline
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box className="flex w-full flex-row justify-between rounded border-t px-6 py-4">
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={autoEmbeddingsId ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{autoEmbeddingsId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/AutoEmbeddingsForm/index.ts
Normal file
1
dashboard/src/features/ai/AutoEmbeddingsForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AutoEmbeddingsForm } from './AutoEmbeddingsForm';
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CubeIcon } from '@/components/ui/v2/icons/CubeIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { EmbeddingsIcon } from '@/components/ui/v2/icons/EmbeddingsIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { AutoEmbeddingsForm } from '@/features/ai/AutoEmbeddingsForm';
|
||||
import { DeleteAutoEmbeddingsModal } from '@/features/ai/DeleteAutoEmbeddingsModal';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { AutoEmbeddingsConfiguration } from 'pages/[workspaceSlug]/[appSlug]/ai/auto-embeddings';
|
||||
|
||||
interface AutoEmbeddingsConfigurationsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
*/
|
||||
autoEmbeddingsConfigurations: AutoEmbeddingsConfiguration[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function AutoEmbeddingsList({
|
||||
autoEmbeddingsConfigurations,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: AutoEmbeddingsConfigurationsListProps) {
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewAutoEmbeddingsConfiguration = async (
|
||||
autoEmbeddingsConfiguration: AutoEmbeddingsConfiguration,
|
||||
) => {
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Edit {autoEmbeddingsConfiguration?.name ?? 'unset'}</Text>
|
||||
</Box>
|
||||
),
|
||||
component: (
|
||||
<AutoEmbeddingsForm
|
||||
autoEmbeddingsId={autoEmbeddingsConfiguration.id}
|
||||
initialData={{
|
||||
...autoEmbeddingsConfiguration,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAutoEmbeddingsConfiguration = async (
|
||||
autoEmbeddingsConfiguration: AutoEmbeddingsConfiguration,
|
||||
) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteAutoEmbeddingsModal
|
||||
autoEmbeddingsConfiguration={autoEmbeddingsConfiguration}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{autoEmbeddingsConfigurations.map((autoEmbeddingsConfiguration) => (
|
||||
<Box
|
||||
key={autoEmbeddingsConfiguration.id}
|
||||
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||
sx={{
|
||||
[`&:hover`]: {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() =>
|
||||
viewAutoEmbeddingsConfiguration(autoEmbeddingsConfiguration)
|
||||
}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<EmbeddingsIcon className="h-5 w-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{autoEmbeddingsConfiguration?.name ?? 'unset'}
|
||||
</Text>
|
||||
<Tooltip title={autoEmbeddingsConfiguration.updatedAt}>
|
||||
<span className="hidden cursor-pointer text-sm text-slate-500 xs+:flex">
|
||||
Updated{' '}
|
||||
{formatDistanceToNow(
|
||||
new Date(autoEmbeddingsConfiguration.updatedAt),
|
||||
)}{' '}
|
||||
ago
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
aria-label="More options"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-auto' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
viewAutoEmbeddingsConfiguration(autoEmbeddingsConfiguration)
|
||||
}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Text className="font-medium">
|
||||
View {autoEmbeddingsConfiguration?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
deleteAutoEmbeddingsConfiguration(autoEmbeddingsConfiguration)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete {autoEmbeddingsConfiguration?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/AutoEmbeddingsList/index.ts
Normal file
1
dashboard/src/features/ai/AutoEmbeddingsList/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AutoEmbeddingsList } from './AutoEmbeddingsList';
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { useDeleteAssistantMutation } from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteAssistantModalProps {
|
||||
assistant: Assistant;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteAssistantModal({
|
||||
assistant,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteAssistantModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [deleteAssistantMutation] = useDeleteAssistantMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const deleteAssistant = async () => {
|
||||
await deleteAssistantMutation({
|
||||
variables: {
|
||||
id: assistant.assistantID,
|
||||
},
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteAssistant(),
|
||||
{
|
||||
loading: 'Deleting the assistant...',
|
||||
success: `The Assistant has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the Assistant. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Assistant {assistant?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this Assistant?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete ${assistant?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Assistant"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Assistant
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/features/ai/DeleteAssistantModal/index.ts
Normal file
1
dashboard/src/features/ai/DeleteAssistantModal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DeleteAssistantModal } from './DeleteAssistantModal';
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { useDeleteGraphiteAutoEmbeddingsConfigurationMutation } from '@/utils/__generated__/graphite.graphql';
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type ApolloError,
|
||||
} from '@apollo/client';
|
||||
import { type AutoEmbeddingsConfiguration } from 'pages/[workspaceSlug]/[appSlug]/ai/auto-embeddings';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteAutoEmbeddingsModalProps {
|
||||
autoEmbeddingsConfiguration: AutoEmbeddingsConfiguration;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteAutoEmbeddingsModal({
|
||||
autoEmbeddingsConfiguration,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteAutoEmbeddingsModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [deleteAutoEmbeddingsConfiguration] =
|
||||
useDeleteGraphiteAutoEmbeddingsConfigurationMutation({
|
||||
client,
|
||||
});
|
||||
|
||||
const deleteAutoEmbeddingsConfig = async () => {
|
||||
await deleteAutoEmbeddingsConfiguration({
|
||||
variables: {
|
||||
id: autoEmbeddingsConfiguration.id,
|
||||
},
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteAutoEmbeddingsConfig(),
|
||||
{
|
||||
loading: 'Deleting Auto-Embeddings Configuration...',
|
||||
success: `The Auto-Embeddings Configuration has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the Auto-Embeddings Configuration. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Auto-Embeddings Configuration{' '}
|
||||
{autoEmbeddingsConfiguration?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this Auto-Embeddings Configuration?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete ${autoEmbeddingsConfiguration?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Auto-Embeddings Configuration"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Auto-Embeddings Configuration
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteAutoEmbeddingsModal } from './DeleteAutoEmbeddingsModal';
|
||||
219
dashboard/src/features/ai/DevAssistant/DevAssistant.tsx
Normal file
219
dashboard/src/features/ai/DevAssistant/DevAssistant.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { UpgradeToProBanner } from '@/components/common/UpgradeToProBanner';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ArrowUpIcon } from '@/components/ui/v2/icons/ArrowUpIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { MessagesList } from '@/features/ai/DevAssistant/components/MessagesList';
|
||||
import {
|
||||
messagesState,
|
||||
projectMessagesState,
|
||||
sessionIDState,
|
||||
} from '@/features/ai/DevAssistant/state';
|
||||
import { useAdminApolloClient } from '@/features/projects/common/hooks/useAdminApolloClient';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useSendDevMessageMutation,
|
||||
useStartDevSessionMutation,
|
||||
type SendDevMessageMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
const MAX_THREAD_LENGTH = 50;
|
||||
|
||||
export type Message = Omit<
|
||||
SendDevMessageMutation['graphite']['sendDevMessage']['messages'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function DevAssistant() {
|
||||
const { currentProject, currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userInput, setUserInput] = useState('');
|
||||
const setMessages = useSetRecoilState(messagesState);
|
||||
const messages = useRecoilValue(projectMessagesState(currentProject.id));
|
||||
const [storedSessionID, setStoredSessionID] = useRecoilState(sessionIDState);
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
const [startDevSession] = useStartDevSessionMutation({ client: adminClient });
|
||||
const [sendDevMessage] = useSendDevMessageMutation({ client: adminClient });
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setUserInput('');
|
||||
|
||||
let sessionID = storedSessionID;
|
||||
const lastMessage = messages.slice(1).pop(); // The first message is a welcome message, so we exclude it
|
||||
|
||||
let hasBeenAnHourSinceLastMessage = false;
|
||||
if (lastMessage) {
|
||||
hasBeenAnHourSinceLastMessage =
|
||||
new Date().getTime() - new Date(lastMessage.createdAt).getTime() >
|
||||
60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const $messages = [
|
||||
...messages,
|
||||
{
|
||||
id: String(new Date().getTime()),
|
||||
message: userInput,
|
||||
createdAt: null,
|
||||
role: 'user',
|
||||
projectId: currentProject.id,
|
||||
},
|
||||
];
|
||||
|
||||
setMessages($messages);
|
||||
|
||||
if (!sessionID || hasBeenAnHourSinceLastMessage) {
|
||||
const sessionRes = await startDevSession({ client: adminClient });
|
||||
sessionID = sessionRes?.data?.graphite?.startDevSession?.sessionID;
|
||||
setStoredSessionID(sessionID);
|
||||
}
|
||||
|
||||
if (!sessionID) {
|
||||
throw new Error('Failed to start a new session');
|
||||
}
|
||||
|
||||
const {
|
||||
data: {
|
||||
graphite: { sendDevMessage: { messages: newMessages } = {} } = {},
|
||||
} = {},
|
||||
} = await sendDevMessage({
|
||||
variables: {
|
||||
message: userInput,
|
||||
sessionId: sessionID || '',
|
||||
prevMessageID: !hasBeenAnHourSinceLastMessage
|
||||
? lastMessage?.id || ''
|
||||
: '',
|
||||
},
|
||||
});
|
||||
|
||||
let thread = [
|
||||
// remove the temp messages of the user input while we wait for the dev assistant to respond
|
||||
...$messages.filter((item) => item.createdAt),
|
||||
...newMessages
|
||||
|
||||
// remove empty messages
|
||||
.filter((item) => item.message)
|
||||
|
||||
// add the currentProject.id to the new messages
|
||||
.map((item) => ({ ...item, projectId: currentProject.id })),
|
||||
];
|
||||
|
||||
if (thread.length > MAX_THREAD_LENGTH) {
|
||||
thread = thread.slice(thread.length - MAX_THREAD_LENGTH); // keep the thread at a max length of MAX_THREAD_LENGTH
|
||||
}
|
||||
|
||||
setMessages(thread);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
'Failed to send the message to graphite. Please try again later.',
|
||||
{
|
||||
style: getToastStyleProps().style,
|
||||
...getToastStyleProps().error,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget.closest('form');
|
||||
if (form) {
|
||||
form.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentProject.plan.isFree) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<UpgradeToProBanner
|
||||
title="Upgrade to Nhost Pro."
|
||||
description={
|
||||
<Text>
|
||||
Graphite is an addon to the Pro plan. To unlock it, please upgrade
|
||||
to Pro first.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentProject.plan.isFree && !currentProject.config?.ai) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert className="grid w-full grid-flow-col place-content-between items-center gap-2">
|
||||
<Text className="grid grid-flow-row justify-items-start gap-0.5">
|
||||
<Text component="span">
|
||||
To enable graphite, configure the service first in{' '}
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentProject.slug}/settings/ai`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
AI Settings
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Text>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
<MessagesList loading={loading} />
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Box className="relative flex w-full flex-row justify-between p-2">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(event) => {
|
||||
const { value } = event.target;
|
||||
setUserInput(value);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask graphite anything!"
|
||||
className="w-full"
|
||||
required
|
||||
slotProps={{
|
||||
input: { className: 'w-full rounded-none border-none' },
|
||||
}}
|
||||
multiline
|
||||
maxRows={7}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
disabled={!userInput || loading}
|
||||
color="primary"
|
||||
aria-label="Send"
|
||||
type="submit"
|
||||
className="absolute right-2 h-10 w-12 self-end rounded-xl"
|
||||
>
|
||||
{loading ? <ActivityIndicator /> : <ArrowUpIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
export default function LoadingAssistantMessage() {
|
||||
return (
|
||||
<Box className="flex flex-col space-y-4 border-t p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<GraphiteIcon />
|
||||
<Text className="font-bold">Assistant</Text>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<Box
|
||||
className="h-1.5 w-1.5 animate-blinking rounded-full"
|
||||
sx={{ backgroundColor: 'grey.600' }}
|
||||
/>
|
||||
<Box
|
||||
className="h-1.5 w-1.5 animate-blinking rounded-full animate-delay-150"
|
||||
sx={{ backgroundColor: 'grey.600' }}
|
||||
/>
|
||||
<Box
|
||||
className="h-1.5 w-1.5 animate-blinking rounded-full animate-delay-300"
|
||||
sx={{ backgroundColor: 'grey.600' }}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LoadingAssistantMessage } from './LoadingAssistantMessage';
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { type Message } from '@/features/ai/DevAssistant';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { onlyText } from 'react-children-utilities';
|
||||
import Markdown, { type ExtraProps } from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkGFM from 'remark-gfm';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { type ClassAttributes, type HTMLAttributes } from 'react';
|
||||
|
||||
function PreComponent(
|
||||
props: ClassAttributes<HTMLElement> &
|
||||
HTMLAttributes<HTMLElement> &
|
||||
ExtraProps,
|
||||
) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<pre>{children}</pre>
|
||||
<IconButton
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
padding: 0.5,
|
||||
backgroundColor: 'grey.100',
|
||||
}}
|
||||
color="warning"
|
||||
variant="contained"
|
||||
className="absolute top-2 right-2 hidden group-hover:flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(onlyText(children), 'Snippet');
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageBox({ message }: { message: Message }) {
|
||||
const theme = useTheme();
|
||||
const user = useUserData();
|
||||
const isUserMessage = message.role === 'user';
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex flex-col space-y-4 border-t p-4 first:border-t-0"
|
||||
sx={{
|
||||
backgroundColor: isUserMessage && 'background.default',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{message.role === 'assistant' ? (
|
||||
<>
|
||||
<GraphiteIcon />
|
||||
<Text className="font-bold">Assistant</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar
|
||||
className="h-7 w-7 rounded-full"
|
||||
alt={user?.displayName}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
{user?.displayName || 'local'}
|
||||
</Avatar>
|
||||
<Text className="font-bold">
|
||||
{user?.displayName || 'local'} (You)
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Markdown
|
||||
className={twMerge(
|
||||
'prose',
|
||||
theme.palette.mode === 'dark' && 'prose-invert',
|
||||
)}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
remarkPlugins={[remarkGFM]}
|
||||
components={{
|
||||
pre: PreComponent,
|
||||
}}
|
||||
>
|
||||
{message.message}
|
||||
</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MessageBox } from './MessageBox';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { LoadingAssistantMessage } from '@/features/ai/DevAssistant/components/LoadingAssistantMessage';
|
||||
import { MessageBox } from '@/features/ai/DevAssistant/components/MessageBox';
|
||||
import { projectMessagesState } from '@/features/ai/DevAssistant/state';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
interface MessagesListProps {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MessagesList({ loading }: MessagesListProps) {
|
||||
const bottomElement = useRef(null);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const messages = useRecoilValue(projectMessagesState(currentProject.id));
|
||||
|
||||
const scrollToBottom = () =>
|
||||
bottomElement?.current?.scrollIntoView({ behavior: 'instant' });
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading]);
|
||||
|
||||
return (
|
||||
<Box className="flex grow flex-col overflow-auto border-y">
|
||||
{messages.map((message) => (
|
||||
<MessageBox key={message.id} message={message} />
|
||||
))}
|
||||
{loading && <LoadingAssistantMessage />}
|
||||
<div ref={bottomElement} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessagesList);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MessagesList } from './MessagesList';
|
||||
2
dashboard/src/features/ai/DevAssistant/index.ts
Normal file
2
dashboard/src/features/ai/DevAssistant/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './DevAssistant';
|
||||
export { default as DevAssistant } from './DevAssistant';
|
||||
5
dashboard/src/features/ai/DevAssistant/state/index.ts
Normal file
5
dashboard/src/features/ai/DevAssistant/state/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './messages';
|
||||
export { default as messagesState } from './messages';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
export { default as projectMessagesState } from './projectMessages';
|
||||
export { default as sessionIDState } from './session';
|
||||
23
dashboard/src/features/ai/DevAssistant/state/messages.ts
Normal file
23
dashboard/src/features/ai/DevAssistant/state/messages.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type Message } from '@/features/ai/DevAssistant';
|
||||
import { persistAtom } from '@/utils/recoil';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export interface ProjectMessage extends Message {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const messagesState = atom<ProjectMessage[]>({
|
||||
key: 'messages',
|
||||
default: [
|
||||
{
|
||||
id: '0',
|
||||
message:
|
||||
"Hi, I'm your personal Nhost AI assistant. I'm here to help answer questions, assist with tasks, provide information, or just have a conversation about GraphQL!",
|
||||
role: 'assistant',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
effects: [persistAtom],
|
||||
});
|
||||
|
||||
export default messagesState;
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
messagesState,
|
||||
type ProjectMessage,
|
||||
} from '@/features/ai/DevAssistant/state';
|
||||
import { selectorFamily } from 'recoil';
|
||||
|
||||
const projectMessagesState = selectorFamily<ProjectMessage[], string>({
|
||||
key: 'projectMessages',
|
||||
get:
|
||||
(projectId) =>
|
||||
({ get }) => {
|
||||
const messages = get(messagesState);
|
||||
|
||||
return messages.filter(
|
||||
(message) =>
|
||||
message.projectId === projectId || message.projectId === undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default projectMessagesState;
|
||||
10
dashboard/src/features/ai/DevAssistant/state/session.ts
Normal file
10
dashboard/src/features/ai/DevAssistant/state/session.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { persistAtom } from '@/utils/recoil';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
const sessionIDState = atom<string>({
|
||||
key: 'sessionID',
|
||||
default: '',
|
||||
effects: [persistAtom],
|
||||
});
|
||||
|
||||
export default sessionIDState;
|
||||
459
dashboard/src/features/ai/settings/components/AISettings.tsx
Normal file
459
dashboard/src/features/ai/settings/components/AISettings.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
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 { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { ComputeFormSection } from '@/features/services/components/ServiceForm/components/ComputeFormSection';
|
||||
import {
|
||||
Software_Type_Enum,
|
||||
useGetAiSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
import { DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
}),
|
||||
webhookSecret: Yup.string(),
|
||||
synchPeriodMinutes: Yup.number(),
|
||||
organization: Yup.string(),
|
||||
apiKey: Yup.string().required(),
|
||||
compute: Yup.object({
|
||||
cpu: Yup.number().required(),
|
||||
memory: Yup.number().required(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AISettingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AISettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { openDialog } = useDialog();
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [aiServiceEnabled, setAIServiceEnabled] = useState(true);
|
||||
|
||||
const {
|
||||
data: { config: { ai } = {} } = {},
|
||||
loading: loadingAiSettings,
|
||||
error: errorGettingAiSettings,
|
||||
} = useGetAiSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: graphiteVersionsData, loading: loadingGraphiteVersionsData } =
|
||||
useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Graphite,
|
||||
},
|
||||
});
|
||||
|
||||
const graphiteVersions = graphiteVersionsData?.softwareVersions || [];
|
||||
|
||||
const availableVersionsSet = new Set(
|
||||
graphiteVersions.map((el) => el.version),
|
||||
);
|
||||
|
||||
if (ai?.version) {
|
||||
availableVersionsSet.add(ai.version);
|
||||
}
|
||||
|
||||
const availableVersions = Array.from(availableVersionsSet)
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((availableVersion) => ({
|
||||
label: availableVersion,
|
||||
value: availableVersion,
|
||||
}));
|
||||
|
||||
const form = useForm<AISettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
version: {
|
||||
label: ai?.version ?? availableVersions?.at(0)?.label,
|
||||
value: ai?.version ?? availableVersions?.at(0)?.value,
|
||||
},
|
||||
webhookSecret: '',
|
||||
organization: '',
|
||||
apiKey: '',
|
||||
synchPeriodMinutes: 5,
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, reset, watch, setValue } = form;
|
||||
|
||||
const aiSettingsFormValues = watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (ai) {
|
||||
reset({
|
||||
version: {
|
||||
label: ai?.version,
|
||||
value: ai?.version,
|
||||
},
|
||||
webhookSecret: ai?.webhookSecret,
|
||||
synchPeriodMinutes: ai?.autoEmbeddings?.synchPeriodMinutes,
|
||||
apiKey: ai?.openai?.apiKey,
|
||||
organization: ai?.openai?.organization,
|
||||
compute: {
|
||||
cpu: ai?.resources?.compute?.cpu ?? 62,
|
||||
memory: ai?.resources?.compute?.memory ?? 128,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setAIServiceEnabled(!!ai);
|
||||
}, [ai, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loadingGraphiteVersionsData &&
|
||||
availableVersions.length > 0 &&
|
||||
!ai &&
|
||||
!aiSettingsFormValues.version.value
|
||||
) {
|
||||
setValue('version', availableVersions?.at(0));
|
||||
}
|
||||
}, [
|
||||
ai,
|
||||
setValue,
|
||||
availableVersions,
|
||||
aiSettingsFormValues,
|
||||
loadingGraphiteVersionsData,
|
||||
]);
|
||||
|
||||
const toggleAIService = async (enabled: boolean) => {
|
||||
setAIServiceEnabled(enabled);
|
||||
|
||||
if (!enabled && ai) {
|
||||
openDialog({
|
||||
title: 'Confirm Disabling the AI service',
|
||||
component: (
|
||||
<DisableAIServiceConfirmationDialog
|
||||
onCancel={() => setAIServiceEnabled(true)}
|
||||
onServiceDisabled={() => setAIServiceEnabled(false)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingAiSettings || loadingGraphiteVersionsData) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Postgres version..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorGettingAiSettings) {
|
||||
throw errorGettingAiSettings;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: AISettingsFormValues) {
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
ai: {
|
||||
version: formValues.version.value,
|
||||
webhookSecret: formValues.webhookSecret,
|
||||
autoEmbeddings: {
|
||||
synchPeriodMinutes: Number(formValues.synchPeriodMinutes),
|
||||
},
|
||||
openai: {
|
||||
apiKey: formValues.apiKey,
|
||||
organization: formValues.organization,
|
||||
},
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: formValues?.compute?.cpu,
|
||||
memory: formValues?.compute?.memory,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `AI settings are being updated...`,
|
||||
success: `AI settings has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the AI settings!`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
const getAIResourcesCost = () => {
|
||||
const vCPUs = `${
|
||||
aiSettingsFormValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER
|
||||
} vCPUs`;
|
||||
const mem = `${aiSettingsFormValues.compute.memory} MiB Mem`;
|
||||
const details = `${vCPUs} + ${mem}`;
|
||||
|
||||
return `Approximate cost for ${details}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4" sx={{ backgroundColor: 'background.default' }}>
|
||||
<Box className="flex flex-row items-center justify-between rounded-lg border-1 p-4">
|
||||
<Text className="text-lg font-semibold">Enable AI service</Text>
|
||||
<Switch
|
||||
checked={aiServiceEnabled}
|
||||
onChange={(e) => toggleAIService(e.target.checked)}
|
||||
className="self-center"
|
||||
/>
|
||||
</Box>
|
||||
{aiServiceEnabled && (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title={null}
|
||||
description={null}
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<Box className="space-y-4">
|
||||
{availableVersions.length > 0 && (
|
||||
<Box className="space-y-2">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text className="text-lg font-semibold">Version</Text>
|
||||
<Tooltip title="Version of the service to use.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
autoHighlight
|
||||
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="col-span-4"
|
||||
options={availableVersions}
|
||||
error={!!formState.errors?.version?.message}
|
||||
helperText={formState.errors?.version?.message}
|
||||
showCustomOption="auto"
|
||||
customOptionLabel={(value) =>
|
||||
`Use custom value: "${value}"`
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text className="text-lg font-semibold">
|
||||
Webhook Secret
|
||||
</Text>
|
||||
<Tooltip title="Used to validate requests between postgres and the AI service. The AI service will also include the header X-Graphite-Webhook-Secret with this value set when calling external webhooks so the source of the request can be validated.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Input
|
||||
{...register('webhookSecret')}
|
||||
id="webhookSecret"
|
||||
name="webhookSecret"
|
||||
placeholder="Webhook Secret"
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={Boolean(formState.errors.webhookSecret?.message)}
|
||||
helperText={formState.errors.webhookSecret?.message}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text className="text-lg font-semibold">Resources</Text>
|
||||
<Tooltip title="Dedicated resources allocated for the service.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
className="flex items-center justify-between space-x-2"
|
||||
>
|
||||
<span>{getAIResourcesCost()}</span>
|
||||
<b>
|
||||
$
|
||||
{parseFloat(
|
||||
(
|
||||
aiSettingsFormValues.compute.cpu * COST_PER_VCPU
|
||||
).toFixed(2),
|
||||
)}
|
||||
</b>
|
||||
</Alert>
|
||||
|
||||
<ComputeFormSection />
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Text className="text-lg font-semibold">OpenAI</Text>
|
||||
|
||||
<Input
|
||||
{...register('apiKey')}
|
||||
name="apiKey"
|
||||
placeholder="API Key"
|
||||
id="apiKey"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>OpenAI API key</Text>
|
||||
<Tooltip title="Key to use for authenticating API requests to OpenAI">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={Boolean(formState.errors.apiKey?.message)}
|
||||
helperText={formState.errors.apiKey?.message}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('organization')}
|
||||
id="organization"
|
||||
name="organization"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>OpenAI Organization</Text>
|
||||
<Tooltip title="Optional. OpenAI organization to use.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="Organization"
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={Boolean(formState.errors.organization?.message)}
|
||||
helperText={formState.errors.organization?.message}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-2">
|
||||
<Text className="text-lg font-semibold">Auto-Embeddings</Text>
|
||||
<Input
|
||||
{...register('synchPeriodMinutes')}
|
||||
id="synchPeriodMinutes"
|
||||
name="synchPeriodMinutes"
|
||||
type="number"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Synch Period Minutes</Text>
|
||||
<Tooltip title="How often to run the job that keeps embeddings up to date.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="Synch Period Minutes"
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(
|
||||
formState.errors.synchPeriodMinutes?.message,
|
||||
)}
|
||||
helperText={formState.errors.synchPeriodMinutes?.message}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useUpdateConfigMutation } from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DisableAIServiceConfirmationDialogProps {
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Function to be called when the user clicks the confirm button.
|
||||
*/
|
||||
onServiceDisabled: () => void;
|
||||
}
|
||||
|
||||
export default function DisableAIServiceConfirmationDialog({
|
||||
onCancel,
|
||||
onServiceDisabled,
|
||||
}: DisableAIServiceConfirmationDialogProps) {
|
||||
const { closeDialog } = useDialog();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
|
||||
await toast.promise(
|
||||
updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
ai: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Disabling the AI service...',
|
||||
success: () => {
|
||||
onServiceDisabled();
|
||||
closeDialog();
|
||||
return `The service has been disabled.`;
|
||||
},
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while disabling the AI service. Please try again later.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 pt-0 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to disable this service?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button color="error" onClick={handleClick} loading={loading}>
|
||||
Disable
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
2
dashboard/src/features/ai/settings/components/index.ts
Normal file
2
dashboard/src/features/ai/settings/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AISettings';
|
||||
export { default as AISettings } from './AISettings';
|
||||
21
dashboard/src/features/ai/settings/gql/getAISettings.gql
Normal file
21
dashboard/src/features/ai/settings/gql/getAISettings.gql
Normal file
@@ -0,0 +1,21 @@
|
||||
query GetAISettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
ai {
|
||||
version
|
||||
webhookSecret
|
||||
autoEmbeddings {
|
||||
synchPeriodMinutes
|
||||
}
|
||||
openai {
|
||||
apiKey
|
||||
organization
|
||||
}
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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 { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
@@ -134,12 +133,26 @@ export default function AuthServiceVersionSettings() {
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
filterOptions={(options, state) => {
|
||||
if (state.inputValue === version) {
|
||||
return options;
|
||||
}
|
||||
autoHighlight
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
return filterOptions(options, state);
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetAuthenticationSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
auth {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon'
|
||||
import { LockIcon } from '@/components/ui/v2/icons/LockIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TerminalIcon } from '@/components/ui/v2/icons/TerminalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UsersIcon } from '@/components/ui/v2/icons/UsersIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
@@ -86,7 +87,9 @@ function DataBrowserSidebarContent({
|
||||
const isGitHubConnected = !!currentProject?.githubRepository;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
asPath,
|
||||
query: { workspaceSlug, appSlug, dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = router;
|
||||
|
||||
@@ -108,6 +111,8 @@ function DataBrowserSidebarContent({
|
||||
*/
|
||||
const [sidebarMenuTable, setSidebarMenuTable] = useState<string>();
|
||||
|
||||
const sqlEditorHref = `/${workspaceSlug}/${appSlug}/database/browser/default/editor`;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSchema) {
|
||||
return;
|
||||
@@ -258,194 +263,135 @@ function DataBrowserSidebarContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
{schemas && schemas.length > 0 && (
|
||||
<Select
|
||||
renderValue={(option) => (
|
||||
<span className="grid grid-flow-col items-center gap-1">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
slotProps={{
|
||||
listbox: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
popper: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
}}
|
||||
value={selectedSchema}
|
||||
onChange={(_event, value) => setSelectedSchema(value as string)}
|
||||
>
|
||||
{schemas.map((schema) => (
|
||||
<Option
|
||||
className="grid grid-flow-col items-center gap-1"
|
||||
value={schema.schema_name}
|
||||
key={schema.schema_name}
|
||||
>
|
||||
<Text className="text-sm">
|
||||
<Text component="span" color="disabled">
|
||||
schema.
|
||||
</Text>
|
||||
<Text component="span" className="font-medium">
|
||||
{schema.schema_name}
|
||||
</Text>
|
||||
</Text>
|
||||
{(isSchemaLocked(schema.schema_name) || isGitHubConnected) && (
|
||||
<LockIcon
|
||||
className="h-3 w-3"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{isGitHubConnected && (
|
||||
<Box
|
||||
className="mt-1.5 grid grid-flow-row justify-items-start gap-2 rounded-md p-2"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
<Box className="flex h-full flex-col justify-between">
|
||||
<Box className="flex flex-col px-2">
|
||||
{schemas && schemas.length > 0 && (
|
||||
<Select
|
||||
renderValue={(option) => (
|
||||
<span className="grid grid-flow-col items-center gap-1">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
slotProps={{
|
||||
listbox: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
popper: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
}}
|
||||
value={selectedSchema}
|
||||
onChange={(_event, value) => setSelectedSchema(value as string)}
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isSelectedSchemaLocked && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
|
||||
onSidebarItemClick();
|
||||
}}
|
||||
disabled={isGitHubConnected}
|
||||
>
|
||||
New Table
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{schemas && schemas.length > 0 && tablesInSelectedSchema.length === 0 && (
|
||||
<Text className="py-1.5 px-2 text-xs" color="disabled">
|
||||
No tables found.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<nav aria-label="Database navigation">
|
||||
{tablesInSelectedSchema.length > 0 && (
|
||||
<List className="grid gap-1 pb-6">
|
||||
{tablesInSelectedSchema.map((table) => {
|
||||
const tablePath = `${table.table_schema}.${table.table_name}`;
|
||||
const isSelected = `${schemaSlug}.${tableSlug}` === tablePath;
|
||||
const isSidebarMenuOpen = sidebarMenuTable === tablePath;
|
||||
|
||||
return (
|
||||
<ListItem.Root
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
{schemas.map((schema) => (
|
||||
<Option
|
||||
className="grid grid-flow-col items-center gap-1"
|
||||
value={schema.schema_name}
|
||||
key={schema.schema_name}
|
||||
>
|
||||
<Text className="text-sm">
|
||||
<Text component="span" color="disabled">
|
||||
schema.
|
||||
</Text>
|
||||
<Text component="span" className="font-medium">
|
||||
{schema.schema_name}
|
||||
</Text>
|
||||
</Text>
|
||||
{(isSchemaLocked(schema.schema_name) || isGitHubConnected) && (
|
||||
<LockIcon
|
||||
className="h-3 w-3"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{isGitHubConnected && (
|
||||
<Box
|
||||
className="mt-1.5 grid grid-flow-row justify-items-start gap-2 rounded-md p-2"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
{!isSelectedSchemaLocked && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
onSidebarItemClick();
|
||||
}}
|
||||
disabled={isGitHubConnected}
|
||||
>
|
||||
New Table
|
||||
</Button>
|
||||
)}
|
||||
{schemas && schemas.length > 0 && tablesInSelectedSchema.length === 0 && (
|
||||
<Text className="py-1.5 px-2 text-xs" color="disabled">
|
||||
No tables found.
|
||||
</Text>
|
||||
)}
|
||||
<nav aria-label="Database navigation">
|
||||
{tablesInSelectedSchema.length > 0 && (
|
||||
<List className="grid gap-1 pb-6">
|
||||
{tablesInSelectedSchema.map((table) => {
|
||||
const tablePath = `${table.table_schema}.${table.table_name}`;
|
||||
const isSelected = `${schemaSlug}.${tableSlug}` === tablePath;
|
||||
const isSidebarMenuOpen = sidebarMenuTable === tablePath;
|
||||
return (
|
||||
<ListItem.Root
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content menu PaperProps={{ className: 'w-52' }}>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52' }}
|
||||
>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -453,68 +399,135 @@ function DataBrowserSidebarContent({
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
handleDeleteTableClick(
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'error.main' }}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={isSelected}
|
||||
disabled={tablePath === removableTable}
|
||||
className="group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
sx={{
|
||||
paddingRight:
|
||||
(isSelected || isSidebarMenuOpen) &&
|
||||
'2.25rem !important',
|
||||
}}
|
||||
component={NavLink}
|
||||
href={`/${workspaceSlug}/${appSlug}/database/browser/default/${table.table_schema}/${table.table_name}`}
|
||||
onClick={() => {
|
||||
if (onSidebarItemClick) {
|
||||
onSidebarItemClick(`default.${tablePath}`);
|
||||
}
|
||||
}}
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
handleDeleteTableClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'error.main' }}
|
||||
/>
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text>{table.table_name}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={isSelected}
|
||||
disabled={tablePath === removableTable}
|
||||
className="group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
sx={{
|
||||
paddingRight:
|
||||
(isSelected || isSidebarMenuOpen) &&
|
||||
'2.25rem !important',
|
||||
}}
|
||||
component={NavLink}
|
||||
href={`/${workspaceSlug}/${appSlug}/database/browser/default/${table.table_schema}/${table.table_name}`}
|
||||
onClick={() => {
|
||||
if (onSidebarItemClick) {
|
||||
onSidebarItemClick(`default.${tablePath}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItem.Text>{table.table_name}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
<Box className="border-t">
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={asPath === sqlEditorHref}
|
||||
className="flex border group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
component={NavLink}
|
||||
href={sqlEditorHref}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-center space-x-4">
|
||||
<TerminalIcon />
|
||||
<span className="flex">SQL Editor</span>
|
||||
</div>
|
||||
</ListItem.Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -580,7 +593,7 @@ export default function DataBrowserSidebar({
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pt-2 pb-17 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:py-2.5 sm:transition-none',
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 pt-2 pb-17 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:pt-2.5 sm:pb-0 sm:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
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 { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlayIcon } from '@/components/ui/v2/icons/PlayIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Table } from '@/components/ui/v2/Table';
|
||||
import { TableBody } from '@/components/ui/v2/TableBody';
|
||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||
import { TableHead } from '@/components/ui/v2/TableHead';
|
||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useRunSQL } from '@/features/database/dataGrid/hooks/useRunSQL';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { PostgreSQL, sql } from '@codemirror/lang-sql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useResizable } from 'react-resizable-layout';
|
||||
|
||||
export default function SQLEditor() {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [sqlCode, setSQLCode] = useState('');
|
||||
const [track, setTrack] = useState(false);
|
||||
const [cascade, setCascade] = useState(false);
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [isMigration, setIsMigration] = useState(false);
|
||||
const [migrationName, setMigrationName] = useState('');
|
||||
|
||||
const onChange = useCallback((value: string) => setSQLCode(value), []);
|
||||
|
||||
const { runSQL, loading, errorMessage, commandOk, rows, columns } = useRunSQL(
|
||||
sqlCode,
|
||||
track,
|
||||
cascade,
|
||||
readOnly,
|
||||
isMigration,
|
||||
migrationName,
|
||||
);
|
||||
|
||||
const { position, separatorProps } = useResizable({
|
||||
axis: 'y',
|
||||
initial: 400,
|
||||
min: 50,
|
||||
reverse: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="flex flex-1 flex-col justify-center overflow-hidden">
|
||||
<Box className="flex flex-col space-y-2 border-b p-4">
|
||||
<Text className="font-semibold">Raw SQL</Text>
|
||||
<Box className="flex flex-col justify-between space-y-2 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<Box className="flex w-full flex-col space-y-2 lg:flex-row lg:space-x-4 lg:space-y-0 xl:h-10">
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Track this
|
||||
</Text>
|
||||
}
|
||||
checked={track}
|
||||
onChange={(event) => setTrack(event.currentTarget.checked)}
|
||||
/>
|
||||
<Tooltip title="If you are creating tables, views or functions, checking this will also expose them over the GraphQL API as top level fields. Functions only intended to be used as computed fields should not be tracked.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Cascade metadata
|
||||
</Text>
|
||||
}
|
||||
checked={cascade}
|
||||
onChange={(e) => setCascade(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Tooltip title="Cascade actions on all dependent metadata references, like relationships and permissions">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Read only
|
||||
</Text>
|
||||
}
|
||||
checked={readOnly}
|
||||
onChange={(e) => setReadOnly(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Tooltip title="When set to true, the request will be run in READ ONLY transaction access mode which means only select queries will be successful. This flag ensures that the GraphQL schema is not modified and is hence highly performant.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{!isPlatform && (
|
||||
<Box className="flex flex-col space-x-0 space-y-2 xl:flex-row xl:space-x-4 xl:space-y-0">
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
This is a migration
|
||||
</Text>
|
||||
}
|
||||
checked={isMigration}
|
||||
onChange={(e) => setIsMigration(e.target.checked)}
|
||||
/>
|
||||
<Tooltip title="Create a migration file with the SQL statement">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{isMigration && (
|
||||
<Input
|
||||
name="isMigration"
|
||||
id="isMigration"
|
||||
placeholder="migration_name"
|
||||
className="h-auto w-auto max-w-md"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
onChange={(e) => setMigrationName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
disabled={loading || !sqlCode.trim()}
|
||||
variant="contained"
|
||||
className="self-start"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runSQL}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<CodeMirror
|
||||
value={sqlCode}
|
||||
height="100%"
|
||||
className="min-h-[100px] flex-1 overflow-y-auto"
|
||||
theme={theme.palette.mode === 'light' ? githubLight : githubDark}
|
||||
extensions={[sql({ dialect: PostgreSQL })]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="h-2 border-t hover:cursor-row-resize"
|
||||
sx={{
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
{...separatorProps}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="flex items-start overflow-auto p-4"
|
||||
style={{ height: position }}
|
||||
>
|
||||
{loading && (
|
||||
<ActivityIndicator
|
||||
className="mx-auto self-center"
|
||||
circularProgressProps={{
|
||||
className: 'w-5 h-5',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="mx-auto grid grid-flow-row place-content-center gap-2 self-center"
|
||||
>
|
||||
<code>{errorMessage}</code>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && commandOk && (
|
||||
<Alert
|
||||
severity="success"
|
||||
className="mx-auto grid grid-flow-row place-content-center gap-2 self-center"
|
||||
>
|
||||
<code>Success, no rows returned</code>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && (
|
||||
<Table
|
||||
style={{
|
||||
tableLayout: 'auto',
|
||||
}}
|
||||
className="w-auto"
|
||||
>
|
||||
<TableHead
|
||||
sx={{
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<TableRow>
|
||||
{columns.map((header) => (
|
||||
<TableCell
|
||||
key={header}
|
||||
scope="col"
|
||||
className="whitespace-nowrap border px-6 py-3 font-bold"
|
||||
>
|
||||
{header}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<TableRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={String(rowIndex)}
|
||||
// className="px-6 py-4 border whitespace-nowrap"
|
||||
>
|
||||
{row.map((value, valueIndex) => (
|
||||
<TableCell
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${value}-${valueIndex}`}
|
||||
className="whitespace-nowrap border px-6 py-4"
|
||||
>
|
||||
{value}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SQLEditor } from './SQLEditor';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useRunSQL } from './useRunSQL';
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { parseIdentifiersFromSQL } from '@/utils/sql';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function useRunSQL(
|
||||
sqlCode: string,
|
||||
track: boolean,
|
||||
cascade: boolean,
|
||||
readOnly: boolean,
|
||||
isMigration: boolean,
|
||||
migrationName: string,
|
||||
) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [commandOk, setCommandOk] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<string[][]>([[]]);
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
);
|
||||
|
||||
const adminSecret =
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret;
|
||||
|
||||
const toastStyle = getToastStyleProps();
|
||||
|
||||
const createMigration = async (
|
||||
inputSQL: string,
|
||||
migration: string,
|
||||
isCascade: boolean,
|
||||
) => {
|
||||
try {
|
||||
const migrationApiResponse = await fetch(`${appUrl}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify({
|
||||
name: migration,
|
||||
datasource: 'default',
|
||||
up: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: inputSQL,
|
||||
cascade: isCascade,
|
||||
read_only: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: '-- Could not auto-generate a down migration.',
|
||||
cascade: isCascade,
|
||||
read_only: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!migrationApiResponse.ok) {
|
||||
throw new Error('Migration API call failed');
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
};
|
||||
} catch (createMigrationError) {
|
||||
toast.error('An error happened when calling the migration API', {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
|
||||
return {
|
||||
error: createMigrationError,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const sendSQLToHasura = async (
|
||||
inputSQL: string,
|
||||
isCascade: boolean,
|
||||
isReadOnly: boolean,
|
||||
) => {
|
||||
try {
|
||||
if (!inputSQL) {
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
queryApiError: 'No SQL provided',
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${appUrl}/v2/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify({
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: inputSQL,
|
||||
cascade: isCascade,
|
||||
read_only: isReadOnly,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json();
|
||||
const queryApiError =
|
||||
errorResponse?.internal?.error?.message || 'Unknown error';
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: queryApiError,
|
||||
};
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (responseBody?.result_type === 'TuplesOk') {
|
||||
return {
|
||||
result_type: 'TuplesOk',
|
||||
columns: responseBody.result[0],
|
||||
rows: responseBody.result.slice(1),
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (responseBody?.result_type === 'CommandOk') {
|
||||
return {
|
||||
result_type: 'CommandOk',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
// If the result_type is neither TuplesOk nor CommandOk
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: 'Unknown response type',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: error.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateMetadata = async (inputSQL: string) => {
|
||||
const entities = parseIdentifiersFromSQL(inputSQL);
|
||||
|
||||
const tablesOrViewEntities = entities.filter(
|
||||
(entity) => entity.type !== 'function',
|
||||
);
|
||||
const functionEntities = entities.filter(
|
||||
(entity) => entity.type === 'function',
|
||||
);
|
||||
|
||||
const trackTablesOrViews = tablesOrViewEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_table',
|
||||
args: {
|
||||
source: 'default',
|
||||
table: {
|
||||
name,
|
||||
schema,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const trackFunctions = functionEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_function',
|
||||
args: {
|
||||
source: 'default',
|
||||
function: {
|
||||
name,
|
||||
schema,
|
||||
configuration: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const metaDataPayload = {
|
||||
source: 'default',
|
||||
type: 'bulk',
|
||||
args: [...trackTablesOrViews, ...trackFunctions],
|
||||
};
|
||||
|
||||
try {
|
||||
if (entities.length > 0) {
|
||||
const metadataApiResponse = await fetch(`${appUrl}/v1/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify(metaDataPayload),
|
||||
});
|
||||
|
||||
if (!metadataApiResponse.ok) {
|
||||
throw new Error('Metadata API call failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error happened when calling the metadata API', {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runSQL = async () => {
|
||||
setLoading(true);
|
||||
setCommandOk(false);
|
||||
setErrorMessage('');
|
||||
|
||||
if (isMigration) {
|
||||
const { error: createMigrationError } = await createMigration(
|
||||
sqlCode,
|
||||
migrationName,
|
||||
cascade,
|
||||
);
|
||||
|
||||
setCommandOk(!createMigrationError);
|
||||
|
||||
if (createMigrationError) {
|
||||
setErrorMessage('An unknown error occurred');
|
||||
}
|
||||
|
||||
// if running the migration fails then we don't update the metadata
|
||||
if (track && !createMigrationError) {
|
||||
await updateMetadata(sqlCode);
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
result_type,
|
||||
error: $error,
|
||||
columns: $columns,
|
||||
rows: $rows,
|
||||
} = await sendSQLToHasura(sqlCode, cascade, readOnly);
|
||||
|
||||
setCommandOk(result_type === 'CommandOk');
|
||||
setColumns($columns);
|
||||
setRows($rows);
|
||||
setErrorMessage($error);
|
||||
|
||||
// if running the sql fails then we don't update the metadata
|
||||
if (track && !$error) {
|
||||
await updateMetadata(sqlCode);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return {
|
||||
runSQL,
|
||||
loading,
|
||||
errorMessage,
|
||||
commandOk,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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 { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetPostgresSettingsDocument,
|
||||
@@ -136,12 +135,26 @@ export default function DatabaseServiceVersionSettings() {
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
filterOptions={(options, state) => {
|
||||
if (state.inputValue === version) {
|
||||
return options;
|
||||
}
|
||||
autoHighlight
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
return filterOptions(options, state);
|
||||
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"
|
||||
|
||||
@@ -2,9 +2,9 @@ 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 { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
@@ -144,15 +144,11 @@ export default function AuthDomain() {
|
||||
/>
|
||||
</Box>
|
||||
{!currentProject.plan.isFree && (
|
||||
<Box
|
||||
className="grid items-center grid-flow-col gap-1 p-3 rounded-lg shadow-sm place-content-between"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
⚠️ Please note that once you increase the storage, it cannot be
|
||||
reduced.
|
||||
</Text>
|
||||
</Box>
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
Note that volumes can only be increased (not decreased). Also, due
|
||||
to an AWS limitation, the same volume can only be increased once
|
||||
every 6 hours.
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
|
||||
@@ -4,7 +4,7 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
database
|
||||
}
|
||||
}
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
postgres {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
@@ -136,12 +135,26 @@ export default function HasuraServiceVersionSettings() {
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
filterOptions={(options, state) => {
|
||||
if (state.inputValue === version) {
|
||||
return options;
|
||||
}
|
||||
autoHighlight
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
return filterOptions(options, state);
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetHasuraSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
hasura {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useAdminApolloClient } from './useAdminApolloClient';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
|
||||
|
||||
export default function useAdminApolloClient() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'graphql',
|
||||
);
|
||||
|
||||
const adminClient = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: new HttpLink({
|
||||
uri: serviceUrl,
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
adminClient,
|
||||
};
|
||||
}
|
||||
@@ -114,7 +114,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
createdAt: new Date().toISOString(),
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
providersUpdated: true,
|
||||
repositoryProductionBranch: null,
|
||||
nhostBaseFolder: null,
|
||||
plan: null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { CloudIcon } from '@/components/ui/v2/icons/CloudIcon';
|
||||
import { CogIcon } from '@/components/ui/v2/icons/CogIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
@@ -144,6 +145,13 @@ export default function useProjectRoutes() {
|
||||
icon: <ServicesIcon />,
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
relativeMainPath: '/ai',
|
||||
relativePath: '/ai/auto-embeddings',
|
||||
exact: false,
|
||||
label: 'AI',
|
||||
icon: <AIIcon />,
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ProjectFragment } from '@/utils/__generated__/graphql';
|
||||
import { test, vi } from 'vitest';
|
||||
import generateAppServiceUrl, {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
} from './generateAppServiceUrl';
|
||||
|
||||
@@ -138,7 +137,7 @@ test('should be able to override the default remote backend slugs', () => {
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
|
||||
expect(
|
||||
generateAppServiceUrl('test', region, 'hasura', defaultLocalBackendSlugs, {
|
||||
generateAppServiceUrl('test', region, 'hasura', {
|
||||
...defaultRemoteBackendSlugs,
|
||||
hasura: '/lorem-ipsum',
|
||||
}),
|
||||
@@ -187,24 +186,3 @@ test('should construct service URLs based on environment variables', () => {
|
||||
'https://localdev4.nhost.run/v1/functions',
|
||||
);
|
||||
});
|
||||
|
||||
test('should generate a basic subdomain with a custom port if provided', () => {
|
||||
process.env.NEXT_PUBLIC_NHOST_BACKEND_URL = `http://localhost:1338`;
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'auth')).toBe(
|
||||
`http://localhost:1338/v1/auth`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'storage')).toBe(
|
||||
`http://localhost:1338/v1/files`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'graphql')).toBe(
|
||||
`http://localhost:1338/v1/graphql`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'functions')).toBe(
|
||||
`http://localhost:1338/v1/functions`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,6 @@ export default function generateAppServiceUrl(
|
||||
subdomain: string,
|
||||
region: ProjectFragment['region'],
|
||||
service: NhostService,
|
||||
localBackendSlugs = defaultLocalBackendSlugs,
|
||||
remoteBackendSlugs = defaultRemoteBackendSlugs,
|
||||
) {
|
||||
const IS_PLATFORM = isPlatform();
|
||||
@@ -87,12 +86,6 @@ export default function generateAppServiceUrl(
|
||||
return serviceUrls[service];
|
||||
}
|
||||
|
||||
// This is only used when running the dashboard locally against its own
|
||||
// backend.
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return `${process.env.NEXT_PUBLIC_NHOST_BACKEND_URL}${localBackendSlugs[service]}`;
|
||||
}
|
||||
|
||||
const constructedDomain = [
|
||||
subdomain,
|
||||
service,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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 { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetServerlessFunctionsSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
functions_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type ServerlessFunctionsDomainFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function ServerlessFunctionsDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ functions_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { functions_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetServerlessFunctionsSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.functions?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ functions_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Serverless Functions Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const functions_fqdn = watch('functions_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: ServerlessFunctionsDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.functions_fqdn.length > 0
|
||||
? [{ fqdn: [formValues.functions_fqdn] }]
|
||||
: [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
functions: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Serverless Functions domain is being updated...`,
|
||||
success: `Serverless Functions domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the Serverless Functions domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Serverless Functions Domain"
|
||||
description="Enter below your custom domain for Serverless Functions."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('functions_fqdn')}
|
||||
id="functions_fqdn"
|
||||
name="functions_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="functions.mydomain.dev"
|
||||
error={Boolean(formState.errors.functions_fqdn?.message)}
|
||||
helperText={formState.errors.functions_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={functions_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServerlessFunctionsDomain } from './ServerlessFunctionsDomain';
|
||||
@@ -16,7 +16,6 @@ import { useAppClient } from '@/features/projects/common/hooks/useAppClient';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
generateAppServiceUrl,
|
||||
} from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
@@ -110,7 +109,6 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
defaultLocalBackendSlugs,
|
||||
{ ...defaultRemoteBackendSlugs, hasura: '/console' },
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
}
|
||||
|
||||
query GetResources($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
...ServiceResources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface RoleSettingsFormValues {
|
||||
*/
|
||||
authUserDefaultRole: string;
|
||||
/**
|
||||
* Allowed roles for the project.
|
||||
* Default Allowed roles for the project.
|
||||
*/
|
||||
authUserDefaultAllowedRoles: Role[];
|
||||
}
|
||||
@@ -169,8 +169,8 @@ export default function RoleSettings() {
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Allowed Roles"
|
||||
description="Allowed roles are roles users get automatically when they sign up."
|
||||
title="Default Allowed Roles"
|
||||
description="Default Allowed Roles are roles users get automatically when they sign up."
|
||||
docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
|
||||
rootClassName="gap-0"
|
||||
className={twMerge(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
query GetServerlessFunctionsSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
functions {
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -385,7 +385,7 @@ export default function ServiceForm({
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -416,7 +416,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -447,7 +447,7 @@ export default function ServiceForm({
|
||||
</b>
|
||||
</Alert>
|
||||
|
||||
<ComputeFormSection />
|
||||
<ComputeFormSection showTooltip />
|
||||
|
||||
<ReplicasFormSection />
|
||||
|
||||
@@ -460,7 +460,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ComputeFormSection() {
|
||||
interface ComputeFormSectionProps {
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
export default function ComputeFormSection({
|
||||
showTooltip = false,
|
||||
}: ComputeFormSectionProps) {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
@@ -34,14 +40,18 @@ export default function ComputeFormSection() {
|
||||
|
||||
const incrementCompute = () => {
|
||||
const newMemoryValue = formValues.compute.memory + 128;
|
||||
setValue('compute.memory', newMemoryValue);
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO));
|
||||
setValue('compute.memory', newMemoryValue, { shouldDirty: true });
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const decrementCompute = () => {
|
||||
const newMemoryValue = formValues.compute.memory - 128;
|
||||
setValue('compute.memory', newMemoryValue);
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO));
|
||||
setValue('compute.memory', newMemoryValue, { shouldDirty: true });
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -52,24 +62,26 @@ export default function ComputeFormSection() {
|
||||
{formValues.compute.memory}
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Compute resources dedicated for the service. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Compute resources dedicated for the service. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-row items-center justify-between space-x-4">
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetStorageSettingsDocument,
|
||||
@@ -136,12 +135,26 @@ export default function StorageServiceVersionSettings() {
|
||||
<ControlledAutocomplete
|
||||
id="version"
|
||||
name="version"
|
||||
filterOptions={(options, state) => {
|
||||
if (state.inputValue === version) {
|
||||
return options;
|
||||
}
|
||||
autoHighlight
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
const otherOptions = [];
|
||||
|
||||
return filterOptions(options, state);
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetStorageSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
storage {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query getProjectLocales($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
auth {
|
||||
user {
|
||||
locale {
|
||||
|
||||
@@ -18,7 +18,7 @@ fragment JWTSecret on ConfigJWTSecret {
|
||||
}
|
||||
|
||||
query GetEnvironmentVariables($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
global {
|
||||
|
||||
@@ -5,7 +5,7 @@ fragment PermissionVariable on ConfigAuthsessionaccessTokenCustomClaims {
|
||||
}
|
||||
|
||||
query GetRolesPermissions($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
auth {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetSignInMethods($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
provider {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user