Compare commits
41 Commits
release-20
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c640c50c70 | ||
|
|
b3ff6adcc2 | ||
|
|
dbadf59092 | ||
|
|
bedbb82cd7 | ||
|
|
79ce7cae2f | ||
|
|
9c9137f813 | ||
|
|
502abadbae | ||
|
|
b6b67773d1 | ||
|
|
33ce95536d | ||
|
|
11f9ed7507 | ||
|
|
ac6d1b6e01 | ||
|
|
77fba27d12 | ||
|
|
7163854767 | ||
|
|
66bd4504d7 | ||
|
|
a03fb2cf82 | ||
|
|
87a37cfc08 | ||
|
|
6fb0cc27aa | ||
|
|
2c33051f83 | ||
|
|
a9413af6e0 | ||
|
|
f4f0353f2e | ||
|
|
defffd8bc4 | ||
|
|
614c20cbbf | ||
|
|
aef4a0a4fc | ||
|
|
d0c9f4cd17 | ||
|
|
e2646cab55 | ||
|
|
d5077c7ca4 | ||
|
|
c6d5c5cc8c | ||
|
|
f1d9b472d1 | ||
|
|
c6dc7f44df | ||
|
|
3cea460c36 | ||
|
|
4c351714f5 | ||
|
|
3143d66a8e | ||
|
|
8512a7f181 | ||
|
|
e503b8fe8b | ||
|
|
304065ae22 | ||
|
|
68e0622eb0 | ||
|
|
70c6834636 | ||
|
|
a7bde37bba | ||
|
|
1bc615beca | ||
|
|
a58c5cfc96 | ||
|
|
c61228e45d |
@@ -1,5 +1,75 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 502abad: feat: add services health checks indicators to the overview page
|
||||
- b3ff6ad: chore: update title text on service status modal
|
||||
- dbadf59: feat: add project configuration TOML editor to the settings page
|
||||
|
||||
## 1.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 77fba27: fix: postgres version validation when activating ai in ai settings page
|
||||
- ac6d1b6: feat: use name instead of awsName
|
||||
|
||||
## 1.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 87a37cf: fix: remove unnecessary isPlatform check from verify button disable logic on custom domains
|
||||
- @nhost/react-apollo@12.0.2
|
||||
- @nhost/nextjs@2.1.16
|
||||
|
||||
## 1.16.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a9413af: fix: update `GetAllWorkspacesAndProjects` query polling to use exponential backoff
|
||||
- @nhost/react-apollo@12.0.1
|
||||
- @nhost/nextjs@2.1.15
|
||||
|
||||
## 1.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@12.0.0
|
||||
- @nhost/nextjs@2.1.14
|
||||
|
||||
## 1.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c6d5c5c: feat: add toggle switch to enable/disable public access in the database settings
|
||||
|
||||
## 1.15.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@11.0.4
|
||||
- @nhost/nextjs@2.1.13
|
||||
|
||||
## 1.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@11.0.3
|
||||
- @nhost/nextjs@2.1.12
|
||||
|
||||
## 1.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a7bde37: feat: send metadata in the edit form
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1bc615b: feat: improve error message handling in `ErrorToast` component
|
||||
- @nhost/react-apollo@11.0.2
|
||||
- @nhost/nextjs@2.1.11
|
||||
|
||||
## 1.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
2
dashboard/e2e/e2e-tests-project/.gitignore
vendored
Normal file
2
dashboard/e2e/e2e-tests-project/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.secrets
|
||||
.nhost
|
||||
1
dashboard/e2e/e2e-tests-project/nhost/config.yaml
Normal file
1
dashboard/e2e/e2e-tests-project/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Потвърдете смяната на вашия имейл</h2>
|
||||
<p>Използвайте посочения линк, за да повърдите смяната на имейл:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Смени имейл
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждение за смяна на имейл
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Потвърдете вашия имейл</h2>
|
||||
<p>Използвайте посочения линк, за да потвърдите вашия имейл:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Потвърдете имейл
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждаване на имейл
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Смяна на парола</h2>
|
||||
<p>Използвайте посочения линк, за да смените вашата парола:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Смяна на парола
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Смяна на парола
|
||||
@@ -0,0 +1 @@
|
||||
Вашият код е ${code}.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Магически линк за вход</h2>
|
||||
<p>Използвайте посочения линк за защитен и бърз вход:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Вход
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Магически линк за вход
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Potvrzení změny emailové adresy</h2>
|
||||
<p>Použijte tento odkaz k potvrzení změny emailové adresy:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Změnit email
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Změna vaší emailové adresy
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Ověření emailové adresy</h2>
|
||||
<p>Použijte tento odkaz k ověření vaší emailové adresy:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Ověřit emailovou adresu
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Ověření vaší emailové adresy
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Obnova hesla</h2>
|
||||
<p>Použijte tento odkaz k obnovení vašeho hesla:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Obnova hesla
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Obnova hesla
|
||||
@@ -0,0 +1 @@
|
||||
Váš kód je ${code}.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Magický odkaz</h2>
|
||||
<p>Použijte tento odkaz k bezpečnému přihlášení:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Přihlášení
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Bezpečný odkaz k přihlášení
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Confirm Email Change</h2>
|
||||
<p>Use this link to confirm changing email:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Change email
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Change your email address
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Verify Email</h2>
|
||||
<p>Use this link to verify your email:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Verify your email
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Reset Password</h2>
|
||||
<p>Use this link to reset your password:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Reset password
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Reset your password
|
||||
@@ -0,0 +1 @@
|
||||
Your code is ${code}.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Magic Link</h2>
|
||||
<p>Use this link to securely sign in:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Sign In
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Secure sign-in link
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Confirmar cambio de correo electrónico</h2>
|
||||
<p>Utiliza el siguiente enlace para confirmar el cambio de correo:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Cambiar correo electrónico
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Cambiar dirección de correo electrónico
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Verificar correo electrónico</h2>
|
||||
<p>Utilza el siguiente enlace para verificar tu correo:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Verificar correo electrónico
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Verifica tu correo electrónico
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Recuperar contraseña</h2>
|
||||
<p>Utiliza el siguiente enlace para recuperar tu contraseña:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Recuperar contraseña
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Recuperar contraseña
|
||||
@@ -0,0 +1 @@
|
||||
Tu código es ${code}.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Enlace mágico</h2>
|
||||
<p>Utiliza este enlace para iniciar sesión de forma segura:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Iniciar sesión
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Enlace de acceso seguro
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Confirmer changement de courriel</h2>
|
||||
<p>Utilisez ce lien pour confirmer le changement de courriel :</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Changer courriel
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Changez votre adresse courriel
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Vérifiez votre courriel</h2>
|
||||
<p>Utilisez ce lien pour vérifier votre courriel :</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Vérifier courriel
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Vérifier votre courriel
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Réinitialiser votre mot de passe</h2>
|
||||
<p>Utilisez ce lien pour réinitialiser votre mot de passe :</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Réinitialiser mot de passe
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Réinitialiser votre mot de passe
|
||||
@@ -0,0 +1 @@
|
||||
Votre code est ${code}.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Lien magique</h2>
|
||||
<p>Utilisez ce lien pour vous connecter de façon sécurisée :</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Connexion
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Lien de connexion sécurisé
|
||||
@@ -0,0 +1,8 @@
|
||||
${link},
|
||||
${displayName},
|
||||
${email},
|
||||
${ticket},
|
||||
${redirectTo},
|
||||
${serverUrl},
|
||||
${clientUrl},
|
||||
${locale},
|
||||
@@ -0,0 +1 @@
|
||||
${link}, ${displayName}, ${email}, ${ticket}, ${redirectTo}, ${serverUrl}, ${clientUrl}, ${locale}
|
||||
151
dashboard/e2e/e2e-tests-project/nhost/nhost.toml
Normal file
151
dashboard/e2e/e2e-tests-project/nhost/nhost.toml
Normal file
@@ -0,0 +1,151 @@
|
||||
[global]
|
||||
|
||||
[hasura]
|
||||
version = 'v2.33.4-ce'
|
||||
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
|
||||
webhookSecret = '{{ secrets.NHOST_WEBHOOK_SECRET }}'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = '{{ secrets.HASURA_GRAPHQL_JWT_SECRET }}'
|
||||
|
||||
[hasura.settings]
|
||||
corsDomain = ['*']
|
||||
devMode = true
|
||||
enableAllowList = false
|
||||
enableConsole = true
|
||||
enableRemoteSchemaPermissions = false
|
||||
enabledAPIs = ['metadata', 'graphql', 'pgdump', 'config']
|
||||
liveQueriesMultiplexedRefetchInterval = 1000
|
||||
stringifyNumericTypes = false
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
[hasura.events]
|
||||
httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.24.1'
|
||||
|
||||
[auth.elevatedPrivileges]
|
||||
mode = 'disabled'
|
||||
|
||||
[auth.redirections]
|
||||
clientUrl = 'http://localhost:3000'
|
||||
|
||||
[auth.signUp]
|
||||
enabled = true
|
||||
disableNewUsers = false
|
||||
|
||||
[auth.user]
|
||||
[auth.user.roles]
|
||||
default = 'user'
|
||||
allowed = ['user', 'me']
|
||||
|
||||
[auth.user.locale]
|
||||
default = 'en'
|
||||
allowed = ['en']
|
||||
|
||||
[auth.user.gravatar]
|
||||
enabled = true
|
||||
default = 'blank'
|
||||
rating = 'g'
|
||||
|
||||
[auth.user.email]
|
||||
|
||||
[auth.user.emailDomains]
|
||||
|
||||
[auth.session]
|
||||
[auth.session.accessToken]
|
||||
expiresIn = 900
|
||||
|
||||
[auth.session.refreshToken]
|
||||
expiresIn = 2592000
|
||||
|
||||
[auth.method]
|
||||
[auth.method.anonymous]
|
||||
enabled = false
|
||||
|
||||
[auth.method.emailPasswordless]
|
||||
enabled = false
|
||||
|
||||
[auth.method.emailPassword]
|
||||
hibpEnabled = false
|
||||
emailVerificationRequired = true
|
||||
passwordMinLength = 9
|
||||
|
||||
[auth.method.smsPasswordless]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth]
|
||||
[auth.method.oauth.apple]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.azuread]
|
||||
tenant = 'common'
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.bitbucket]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.discord]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.facebook]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.github]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.gitlab]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.google]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.linkedin]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.spotify]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.strava]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.twitch]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.twitter]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.windowslive]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth.workos]
|
||||
enabled = false
|
||||
|
||||
[auth.method.webauthn]
|
||||
enabled = false
|
||||
|
||||
[auth.method.webauthn.attestation]
|
||||
timeout = 60000
|
||||
|
||||
[auth.totp]
|
||||
enabled = false
|
||||
|
||||
[postgres]
|
||||
version = '14.6-20240129-1'
|
||||
|
||||
[provider]
|
||||
|
||||
[storage]
|
||||
version = '0.6.0'
|
||||
|
||||
[observability]
|
||||
[observability.grafana]
|
||||
adminPassword = '{{ secrets.GRAFANA_ADMIN_PASSWORD }}'
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "1.14.0",
|
||||
"version": "1.18.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -21,17 +21,20 @@
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.9",
|
||||
"@codemirror/lang-sql": "^6.6.2",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/legacy-modes": "^6.4.0",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@fontsource/inter": "^5.0.17",
|
||||
"@fontsource/roboto-mono": "^5.0.17",
|
||||
"@graphiql/react": "^0.20.3",
|
||||
"@graphiql/react": "^0.22.3",
|
||||
"@graphiql/toolkit": "^0.9.1",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@mui/base": "5.0.0-beta.31",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/system": "^5.15.14",
|
||||
@@ -45,6 +48,7 @@
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.15.3",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"analytics-node": "^6.2.0",
|
||||
@@ -53,7 +57,7 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"framer-motion": "^10.18.0",
|
||||
"generate-password": "^1.7.1",
|
||||
"graphiql": "^3.1.1",
|
||||
"graphiql": "^3.3.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-request": "^6.1.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
className="flex w-full flex-auto flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { SlidersIcon } from '@/components/ui/v2/icons/SlidersIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
@@ -27,12 +28,17 @@ interface SettingsNavLinkProps extends ListItemButtonProps {
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* Class name passed to the text element.
|
||||
*/
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
function SettingsNavLink({
|
||||
exact = true,
|
||||
href,
|
||||
children,
|
||||
textClassName,
|
||||
...props
|
||||
}: SettingsNavLinkProps) {
|
||||
const router = useRouter();
|
||||
@@ -52,7 +58,7 @@ function SettingsNavLink({
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Text>{children}</ListItem.Text>
|
||||
<ListItem.Text className={textClassName}>{children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
@@ -114,13 +120,13 @@ export default function SettingsSidebar({
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
'absolute top-0 z-[35] flex h-full w-full flex-col justify-between overflow-auto border-r-1 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:pb-0 md:pt-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<nav aria-label="Settings navigation">
|
||||
<nav aria-label="Settings navigation" className="px-2">
|
||||
<List className="grid gap-2">
|
||||
<SettingsNavLink
|
||||
href="/general"
|
||||
@@ -220,6 +226,20 @@ export default function SettingsSidebar({
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
<Box className="border-t">
|
||||
<SettingsNavLink
|
||||
href="/editor"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
className="flex w-full border group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
textClassName="flex w-full justify-center"
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-center space-x-4 py-2.5">
|
||||
<SlidersIcon />
|
||||
<span className="flex">Configuration Editor</span>
|
||||
</div>
|
||||
</SettingsNavLink>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
|
||||
132
dashboard/src/components/presentational/CodeBlock/CodeBlock.tsx
Normal file
132
dashboard/src/components/presentational/CodeBlock/CodeBlock.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ForwardedRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from './CopyToClipboardButton';
|
||||
import { getNodeText } from './getNodeText';
|
||||
|
||||
export interface CodeBlockPropsBase {
|
||||
filename?: string;
|
||||
/**
|
||||
* Color of the filename text and the border underneath it when content is being shown.
|
||||
*/
|
||||
filenameColor?: string;
|
||||
/**
|
||||
* Text of the toast that appears when the code is copied to the clipboard.
|
||||
*/
|
||||
copyToClipboardToastTitle?: string;
|
||||
}
|
||||
|
||||
export type CodeBlockProps = CodeBlockPropsBase &
|
||||
Omit<ComponentPropsWithoutRef<'div'>, keyof CodeBlockPropsBase>;
|
||||
|
||||
/**
|
||||
* Different from CodeGroup because we cannot use Headless UI's Tab component outside a Tab.Group
|
||||
* Styling should look the same though.
|
||||
*/
|
||||
function CodeTabBar({
|
||||
filename,
|
||||
filenameColor,
|
||||
children,
|
||||
}: {
|
||||
filename: string;
|
||||
filenameColor?: string;
|
||||
children?: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex text-xs leading-6 text-slate-400">
|
||||
<div
|
||||
className="flex flex-none items-center border-b border-t border-t-transparent px-4 py-1"
|
||||
style={{ color: filenameColor, borderBottomColor: filenameColor }}
|
||||
>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="bg-codeblock-tabs flex flex-auto items-center rounded-t border border-slate-500/30">
|
||||
{children && (
|
||||
<div className="flex flex-auto items-center justify-end space-x-4 px-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyToClipboardButtonProps
|
||||
extends Partial<
|
||||
ComponentPropsWithoutRef<typeof CopyToClipboardButtonOriginal>
|
||||
> {
|
||||
filenameColor?: string;
|
||||
tooltipColor?: string;
|
||||
toastTitle?: string;
|
||||
}
|
||||
|
||||
function CopyToClipboardButton({
|
||||
tooltipColor,
|
||||
filenameColor,
|
||||
textToCopy,
|
||||
toastTitle,
|
||||
...props
|
||||
}: CopyToClipboardButtonProps) {
|
||||
return (
|
||||
<CopyToClipboardButtonOriginal
|
||||
textToCopy={textToCopy}
|
||||
title={toastTitle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const CodeBlock = forwardRef(
|
||||
(
|
||||
{
|
||||
filename,
|
||||
filenameColor,
|
||||
children,
|
||||
className,
|
||||
copyToClipboardToastTitle,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.200',
|
||||
}}
|
||||
className={clsx(
|
||||
'not-prose relative mt-5 px-2',
|
||||
filename && 'pt-2',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{filename ? (
|
||||
<CodeTabBar filename={filename} filenameColor={filenameColor}>
|
||||
<CopyToClipboardButton
|
||||
filenameColor={filenameColor}
|
||||
textToCopy={getNodeText(children)}
|
||||
toastTitle={copyToClipboardToastTitle}
|
||||
className="relative"
|
||||
/>
|
||||
</CodeTabBar>
|
||||
) : (
|
||||
<CopyToClipboardButton
|
||||
filenameColor={filenameColor}
|
||||
textToCopy={getNodeText(children)}
|
||||
toastTitle={copyToClipboardToastTitle}
|
||||
className="absolute right-3 top-0"
|
||||
/>
|
||||
)}
|
||||
<pre className="overflow-x-auto">
|
||||
<code className="font-mono">{children}</code>
|
||||
</pre>
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
} from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
export function CopyToClipboardButton({
|
||||
textToCopy,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
textToCopy: string;
|
||||
title: string;
|
||||
} & IconButtonProps) {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Hide copy button if the browser does not support it
|
||||
if (typeof window !== 'undefined' && !navigator?.clipboard) {
|
||||
console.error(
|
||||
"The browser's Clipboard API is unavailable. The Clipboard API is only available on HTTPS.",
|
||||
);
|
||||
setDisabled(true);
|
||||
} else {
|
||||
setDisabled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Hide copy button if you would copy an empty string
|
||||
if (!textToCopy || disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className={clsx('group', className)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
copy(textToCopy, title);
|
||||
}}
|
||||
aria-label={textToCopy}
|
||||
{...props}
|
||||
>
|
||||
<CopyIcon className="top-5 h-4 w-4" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Gets the text from a component as if you selected it with a mouse and copied it.
|
||||
export const getNodeText = (node: any): string => {
|
||||
if (['string', 'number'].includes(typeof node)) {
|
||||
// Convert number into string
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
if (node instanceof Array) {
|
||||
return node.map(getNodeText).join('');
|
||||
}
|
||||
|
||||
if (typeof node === 'object' && node?.props?.children) {
|
||||
return getNodeText(node.props.children);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
CodeBlock,
|
||||
type CodeBlockProps,
|
||||
type CodeBlockPropsBase,
|
||||
} from './CodeBlock';
|
||||
|
||||
export type { CodeBlockPropsBase, CodeBlockProps };
|
||||
export { CodeBlock };
|
||||
28
dashboard/src/components/ui/v2/Accordion/Accordion.tsx
Normal file
28
dashboard/src/components/ui/v2/Accordion/Accordion.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionProps as MaterialAccordionProps } from '@mui/material/Accordion';
|
||||
|
||||
import MaterialAccordion, { accordionClasses } from '@mui/material/Accordion';
|
||||
|
||||
export interface AccordionProps extends MaterialAccordionProps {}
|
||||
|
||||
const Accordion = styled(MaterialAccordion)<AccordionProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
borderBottom: `transparent solid 0px`,
|
||||
boxShadow: `none`,
|
||||
[`&.${accordionClasses.disabled}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
[`&.${accordionClasses.root}`]: {
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
[`&.${accordionClasses.expanded}`]: {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
Accordion.displayName = 'NhostAccordion';
|
||||
|
||||
export default Accordion;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionActionsProps as MaterialAccordionActionsProps } from '@mui/material/AccordionActions';
|
||||
|
||||
import MaterialAccordionActions from '@mui/material/AccordionActions';
|
||||
|
||||
export interface AccordionActionsProps extends MaterialAccordionActionsProps {}
|
||||
|
||||
const AccordionActions = styled(
|
||||
MaterialAccordionActions,
|
||||
)<AccordionActionsProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
}));
|
||||
|
||||
AccordionActions.displayName = 'NhostAccordionActions';
|
||||
|
||||
export default AccordionActions;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionDetailsProps as MaterialAccordionDetailsProps } from '@mui/material/AccordionDetails';
|
||||
|
||||
import MaterialAccordionDetails from '@mui/material/AccordionDetails';
|
||||
|
||||
export interface AccordionDetailsProps extends MaterialAccordionDetailsProps {}
|
||||
|
||||
const AccordionDetails = styled(
|
||||
MaterialAccordionDetails,
|
||||
)<AccordionDetailsProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
}));
|
||||
|
||||
AccordionDetails.displayName = 'NhostAccordionDetails';
|
||||
|
||||
export default AccordionDetails;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionSummaryProps as MaterialAccordionSummaryProps } from '@mui/material/AccordionSummary';
|
||||
|
||||
import MaterialAccordionSummary, {
|
||||
accordionSummaryClasses,
|
||||
} from '@mui/material/AccordionSummary';
|
||||
|
||||
export interface AccordionSummaryProps extends MaterialAccordionSummaryProps {}
|
||||
|
||||
const AccordionSummary = styled(
|
||||
MaterialAccordionSummary,
|
||||
)<AccordionSummaryProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
[`&.${accordionSummaryClasses.disabled}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
AccordionSummary.displayName = 'NhostAccordionSummary';
|
||||
|
||||
export default AccordionSummary;
|
||||
19
dashboard/src/components/ui/v2/Accordion/index.ts
Normal file
19
dashboard/src/components/ui/v2/Accordion/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import AccordionRoot from './Accordion';
|
||||
import AccordionActions from './AccordionActions';
|
||||
import AccordionDetails from './AccordionDetails';
|
||||
import AccordionSummary from './AccordionSummary';
|
||||
|
||||
export { default as BaseAccordion } from './Accordion';
|
||||
export * from './AccordionActions';
|
||||
export { default as AccordionActions } from './AccordionActions';
|
||||
export * from './AccordionDetails';
|
||||
export { default as AccordionDetails } from './AccordionDetails';
|
||||
export * from './AccordionSummary';
|
||||
export { default as AccordionSummary } from './AccordionSummary';
|
||||
|
||||
export const Accordion = {
|
||||
Root: AccordionRoot,
|
||||
Details: AccordionDetails,
|
||||
Summary: AccordionSummary,
|
||||
Actions: AccordionActions,
|
||||
};
|
||||
44
dashboard/src/components/ui/v2/Badge/Badge.tsx
Normal file
44
dashboard/src/components/ui/v2/Badge/Badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { BadgeProps as MaterialBadgeProps } from '@mui/material/Badge';
|
||||
import MaterialBadge from '@mui/material/Badge';
|
||||
import type { ElementType } from 'react';
|
||||
|
||||
export interface BadgeProps extends MaterialBadgeProps {
|
||||
/**
|
||||
* Custom component for the root node.
|
||||
*/
|
||||
component?: ElementType;
|
||||
}
|
||||
|
||||
const Badge = styled(MaterialBadge)<BadgeProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
'& .MuiBadge-dot': {
|
||||
minWidth: '0.625rem',
|
||||
minHeight: '0.625rem',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
'& .MuiBadge-standard': {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: '0.625rem',
|
||||
height: '0.625rem',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
'& .MuiBadge-colorError': {
|
||||
backgroundColor: theme.palette.error.main,
|
||||
},
|
||||
'& .MuiBadge-colorWarning': {
|
||||
backgroundColor: theme.palette.warning.main,
|
||||
},
|
||||
'& .MuiBadge-colorSuccess': {
|
||||
backgroundColor: theme.palette.success.dark,
|
||||
},
|
||||
}));
|
||||
|
||||
Badge.displayName = 'NhostBadge';
|
||||
|
||||
export default Badge;
|
||||
2
dashboard/src/components/ui/v2/Badge/index.ts
Normal file
2
dashboard/src/components/ui/v2/Badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Badge';
|
||||
export { default as Badge } from './Badge';
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
||||
import type { DialogTitleProps } from '@/components/ui/v2/Dialog';
|
||||
import { styled } from '@mui/material';
|
||||
import type { DialogProps as MaterialDialogProps } from '@mui/material/Dialog';
|
||||
import MaterialDialog from '@mui/material/Dialog';
|
||||
import type { DialogActionsProps } from '@mui/material/DialogActions';
|
||||
import type { DialogContentProps } from '@mui/material/DialogContent';
|
||||
import type { DialogContentTextProps } from '@mui/material/DialogContentText';
|
||||
import type { DialogTitleProps } from '@mui/material/DialogTitle';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ const getInternalErrorMessage = (
|
||||
|
||||
if (error.name === 'ApolloError') {
|
||||
// @ts-ignore
|
||||
const internalError = error.graphQLErrors?.[0]?.extensions?.internal as {
|
||||
error: { message: string };
|
||||
};
|
||||
return internalError?.error?.message || null;
|
||||
const graphqlError = error.graphQLErrors?.[0];
|
||||
const graphqlExtensionsError = graphqlError?.extensions?.internal
|
||||
?.error as { message: string };
|
||||
return graphqlError.message || graphqlExtensionsError?.message || null;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function ExclamationFilledIcon(
|
||||
props: IconProps,
|
||||
ref: ForwardedRef<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="7"
|
||||
height="7"
|
||||
viewBox="0 0 7 7"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Exclamation mark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7 3.5C7 5.433 5.433 7 3.5 7C1.567 7 0 5.433 0 3.5C0 1.567 1.567 0 3.5 0C5.433 0 7 1.567 7 3.5ZM3.96667 5.36667C3.96667 5.6244 3.75773 5.83333 3.5 5.83333C3.24227 5.83333 3.03333 5.6244 3.03333 5.36667C3.03333 5.10893 3.24227 4.9 3.5 4.9C3.75773 4.9 3.96667 5.10893 3.96667 5.36667ZM3.5 1.16667C3.20564 1.16667 2.97296 1.41615 2.99345 1.70979L3.16724 4.20075C3.17943 4.37554 3.32478 4.51111 3.5 4.51111C3.67522 4.51111 3.82057 4.37554 3.83276 4.20075L4.00655 1.70979C4.02704 1.41615 3.79436 1.16667 3.5 1.16667Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ExclamationFilledIcon.displayName = 'NhostExclamationFilledIcon';
|
||||
|
||||
export default forwardRef(ExclamationFilledIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ExclamationFilledIcon } from './ExclamationFilledIcon';
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ServicesOutlinedIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Services"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.8521 7.06294C15.8143 7.03192 15.485 6.77435 14.7986 6.73709C14.7878 6.7365 14.7769 6.73597 14.7659 6.73549C14.7223 6.7336 14.6772 6.7326 14.6307 6.7326C14.458 6.73338 14.2857 6.74604 14.1147 6.77049C14.0763 6.77598 14.0379 6.78208 13.9996 6.78876C13.9966 6.76748 13.9934 6.74642 13.9899 6.72556C13.9367 6.41145 13.8247 6.14509 13.6926 5.92522C13.5869 5.74941 13.4683 5.60332 13.3565 5.48631C13.1377 5.25722 12.9451 5.13955 12.9269 5.12843L12.7118 5L12.5703 5.21132C12.4532 5.39906 12.3569 5.59952 12.2832 5.80884C12.2455 5.916 12.2137 6.02549 12.188 6.13679C12.1785 6.17826 12.17 6.21957 12.1626 6.26069C12.0784 6.72436 12.1218 7.16514 12.2887 7.56346C12.3311 7.66471 12.3815 7.76323 12.4399 7.85868C12.0678 8.07332 11.471 8.12616 11.3503 8.13083H1.46954C1.21146 8.1312 1.00204 8.34712 1.00063 8.6143C0.989105 9.51047 1.13576 10.4013 1.43337 11.2429C1.77374 12.1671 2.28012 12.8478 2.93893 13.2644C3.67717 13.7325 4.87658 14 6.23616 14C6.85036 14.0019 7.4634 13.9444 8.06725 13.8281C8.90666 13.6685 9.71439 13.3648 10.457 12.9294C11.0689 12.5625 11.6196 12.0958 12.0879 11.5472C12.8644 10.6371 13.3296 9.62426 13.6755 8.72167C13.6783 8.7144 13.6811 8.70714 13.6839 8.69989H13.8221C14.6792 8.69989 15.2062 8.3448 15.4969 8.04724C15.69 7.85746 15.8408 7.62628 15.9386 7.36986L16 7.18397L15.8521 7.06294ZM11.9029 9.4592C11.6843 9.49512 11.4987 9.51062 11.3978 9.51453L11.374 9.51544H2.35702C2.41226 9.93468 2.51073 10.3477 2.65136 10.7474C2.90985 11.445 3.24967 11.8492 3.60646 12.0748L3.60778 12.0757C4.05853 12.3615 4.98755 12.6153 6.23616 12.6153H6.24016C6.77497 12.6171 7.30873 12.567 7.83441 12.4657L7.83733 12.4652C8.53499 12.3325 9.20535 12.0806 9.82092 11.7205C10.31 11.4265 10.7488 11.0538 11.1211 10.6177M11.9029 9.4592C11.6864 9.86254 11.4322 10.2531 11.1211 10.6177L11.9029 9.4592Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.38514 7.54505H3.7092C3.77306 7.54505 3.8248 7.49328 3.8248 7.42944V6.25005C3.82516 6.18619 3.77369 6.13415 3.70985 6.13379C3.70964 6.13379 3.70941 6.13379 3.7092 6.13379H2.38514C2.32128 6.13379 2.26953 6.18556 2.26953 6.24939V6.25005V7.42942C2.26953 7.49328 2.32128 7.54505 2.38514 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.21003 7.54505H5.53409C5.59794 7.54505 5.64969 7.49328 5.64969 7.42944V6.25005C5.65005 6.18619 5.59857 6.13415 5.53472 6.13379C5.53451 6.13379 5.53427 6.13379 5.53407 6.13379H4.21001C4.14579 6.13379 4.09375 6.18583 4.09375 6.25005V7.42942C4.09413 7.49339 4.14606 7.54505 4.21003 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.06192 7.54505H7.38597C7.44983 7.54505 7.50157 7.49328 7.50157 7.42944V6.25005C7.50193 6.18619 7.45046 6.13415 7.3866 6.13379C7.38639 6.13379 7.38616 6.13379 7.38595 6.13379H6.06189C5.99804 6.13379 5.94629 6.18556 5.94629 6.24939V6.25005V7.42942C5.94631 7.49328 5.99808 7.54505 6.06192 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.89295 7.54505H9.21701C9.28097 7.54505 9.33291 7.49341 9.33326 7.42944V6.25005C9.33326 6.18583 9.28122 6.13379 9.21701 6.13379H7.89295C7.82909 6.13379 7.77734 6.18556 7.77734 6.24939V6.25005V7.42942C7.77734 7.49328 7.82911 7.54505 7.89295 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.21001 5.84874H5.53406C5.59801 5.84839 5.64967 5.79643 5.64967 5.73249V4.55311C5.64967 4.48925 5.5979 4.4375 5.53406 4.4375H4.21001C4.14606 4.4375 4.09411 4.48914 4.09375 4.55311V5.73249C4.09411 5.79656 4.14594 5.84839 4.21001 5.84874Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.06189 5.84874H7.38595C7.4499 5.84839 7.50156 5.79643 7.50156 5.73249V4.55311C7.50156 4.48925 7.44979 4.4375 7.38595 4.4375H6.06189C5.99804 4.4375 5.94629 4.48927 5.94629 4.55311V5.73249C5.94629 5.79643 5.99795 5.84839 6.06189 5.84874Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.89295 5.84874H9.21701C9.28108 5.84839 9.33291 5.79656 9.33326 5.73249V4.55311C9.33291 4.48914 9.28097 4.4375 9.21701 4.4375H7.89295C7.82909 4.4375 7.77734 4.48927 7.77734 4.55311V5.73249C7.77734 5.79643 7.82901 5.84839 7.89295 5.84874Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.89295 4.1515H9.21701C9.28097 4.1515 9.33291 4.09983 9.33326 4.03589V2.85584C9.33291 2.79188 9.28097 2.74023 9.21701 2.74023H7.89295C7.82909 2.74023 7.77734 2.79198 7.77734 2.85584V4.03587C7.77734 4.09973 7.82911 4.1515 7.89295 4.1515Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.73963 7.54505H11.0637C11.1277 7.54505 11.1796 7.49341 11.1799 7.42944V6.25005C11.1799 6.18583 11.1279 6.13379 11.0637 6.13379H9.73963C9.67579 6.13379 9.62402 6.18556 9.62402 6.24939V6.25005V7.42942C9.62402 7.49328 9.67579 7.54505 9.73963 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesOutlinedIcon.displayName = 'NhostServicesIcon';
|
||||
|
||||
export default ServicesOutlinedIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesOutlinedIcon } from './ServicesOutlinedIcon';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function SlidersIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Sliders"
|
||||
{...props}
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
SlidersIcon.displayName = 'NhostSlidersIcon';
|
||||
|
||||
export default SlidersIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SlidersIcon } from './SlidersIcon';
|
||||
@@ -32,8 +32,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
import { DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
|
||||
const MIN_POSTGRES_VERSION_SUPPORTING_AI = '14.6-20231018-1';
|
||||
import { isPostgresVersionValidForAI } from '@/features/ai/settings/utils/isPostgresVersionValidForAI';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
@@ -165,7 +164,7 @@ export default function AISettings() {
|
||||
]);
|
||||
|
||||
const toggleAIService = async (enabled: boolean) => {
|
||||
if (postgresVersion < MIN_POSTGRES_VERSION_SUPPORTING_AI) {
|
||||
if (!isPostgresVersionValidForAI(postgresVersion)) {
|
||||
toast.error(
|
||||
'In order to enable the AI service you need to update your database version to 14.6-20231018-1 or newer.',
|
||||
{
|
||||
@@ -495,3 +494,4 @@ export default function AISettings() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as isPostgresVersionValidForAI } from './isPostgresVersionValidForAI';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test, vi } from 'vitest';
|
||||
|
||||
import isPostgresVersionValidForAI from './isPostgresVersionValidForAI';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test('greater than minimum version, minor version with two digits, should be valid', () => {
|
||||
const postgresVersion = '14.11-20240515-1';
|
||||
expect(isPostgresVersionValidForAI(postgresVersion)).toBe(true);
|
||||
});
|
||||
|
||||
test('less than minimum version, should be invalid', () => {
|
||||
const postgresVersion = '14.6-20221110-1';
|
||||
expect(isPostgresVersionValidForAI(postgresVersion)).toBe(false);
|
||||
});
|
||||
|
||||
test('equal to minimum version, should be valid', () => {
|
||||
const postgresVersion = '14.6-20231018-1';
|
||||
expect(isPostgresVersionValidForAI(postgresVersion)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Check if the given postgres version is valid for enabling AI in the project
|
||||
*
|
||||
* @param postgresVersion - Postgres version used in the project.
|
||||
* @returns Whether is valid for enabling AI.
|
||||
*/
|
||||
export default function isPostgresVersionValidForAI(
|
||||
postgresVersion: string,
|
||||
): boolean {
|
||||
const MIN_POSTGRES_VERSION_SUPPORTING_AI = '14.6-20231018-1';
|
||||
|
||||
if (/^14\.6-/.test(postgresVersion)) {
|
||||
return postgresVersion >= MIN_POSTGRES_VERSION_SUPPORTING_AI;
|
||||
}
|
||||
|
||||
// Note: No need to account for versions less than 14.6
|
||||
return true;
|
||||
}
|
||||
@@ -31,9 +31,10 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
import kebabCase from 'just-kebab-case';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import type { RemoteAppUser } from 'pages/[workspaceSlug]/[appSlug]/users';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -76,6 +77,21 @@ export const EditUserFormValidationSchema = Yup.object({
|
||||
locale: Yup.string(),
|
||||
defaultRole: Yup.string(),
|
||||
roles: Yup.array().of(Yup.boolean()),
|
||||
metadata: Yup.string().test(
|
||||
'is-valid-json',
|
||||
'Metadata must be valid JSON or empty',
|
||||
(value) => {
|
||||
if (value === '') {
|
||||
return true;
|
||||
} // Allow empty string as valid input
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export type EditUserFormValues = Yup.InferType<
|
||||
@@ -116,14 +132,55 @@ export default function EditUserForm({
|
||||
locale: user.locale,
|
||||
defaultRole: user.defaultRole,
|
||||
roles: roles.map((role) => Object.values(role)[0]),
|
||||
metadata: user?.metadata ? JSON.stringify(user.metadata, null, 2) : '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors, dirtyFields, isSubmitting, isValidating },
|
||||
} = form;
|
||||
|
||||
const handleMetadataError = useMemo(() => {
|
||||
const debouncedSetError = debounce((value) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
// Only set an error if JSON parsing fails
|
||||
} catch (error) {
|
||||
setError('metadata', {
|
||||
type: 'manual',
|
||||
message: 'Invalid JSON format',
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
call: debouncedSetError,
|
||||
cancel: debouncedSetError.cancel, // lodash debounce provides a cancel method to stop the delayed function
|
||||
};
|
||||
}, [setError]);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
if (value === '') {
|
||||
clearErrors('metadata'); // Clear errors when the input is explicitly cleared
|
||||
handleMetadataError.cancel(); // Cancel any debounced error checks
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
clearErrors('metadata'); // Clear errors when valid JSON is entered
|
||||
handleMetadataError.cancel(); // Cancel pending debounced error checks
|
||||
} catch (error) {
|
||||
handleMetadataError.call(value); // Call the debounced error setter
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearErrors, handleMetadataError],
|
||||
);
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -467,6 +524,28 @@ export default function EditUserForm({
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
<Box component="section" className="grid grid-flow-row gap-8 p-6">
|
||||
<Input
|
||||
{...register('metadata', { onChange: handleMetadataChange })}
|
||||
id="metadata"
|
||||
label="Metadata"
|
||||
variant="inline"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.metadata}
|
||||
fullWidth
|
||||
multiline
|
||||
inputProps={{
|
||||
className: 'resize-y min-h-[130px]',
|
||||
}}
|
||||
helperText={
|
||||
errors.metadata
|
||||
? errors.metadata.message
|
||||
: 'Enter valid JSON. This can be a number, boolean, array, or object.'
|
||||
}
|
||||
maxRows={100}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="grid w-full flex-shrink-0 snap-end grid-flow-col justify-between gap-3 place-self-end border-t-1 p-2">
|
||||
|
||||
@@ -113,6 +113,9 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
phoneNumber: values.phoneNumber,
|
||||
phoneNumberVerified: values.phoneNumberVerified,
|
||||
locale: values.locale,
|
||||
...(values?.metadata !== undefined && values.metadata !== ''
|
||||
? { metadata: JSON.parse(values.metadata) }
|
||||
: { metadata: null }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
@@ -7,11 +11,32 @@ import type { InputProps } from '@/components/ui/v2/Input';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { useGetPostgresSettingsQuery } from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const databasePublicAccessValidationSchema = Yup.object({
|
||||
enablePublicAccess: Yup.bool(),
|
||||
});
|
||||
|
||||
type DatabasePublicAccessFormValues = Yup.InferType<
|
||||
typeof databasePublicAccessValidationSchema
|
||||
>;
|
||||
|
||||
export default function DatabaseConnectionInfo() {
|
||||
const { openDialog } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { data, loading, error } = useGetPostgresSettingsQuery({
|
||||
@@ -19,6 +44,61 @@ export default function DatabaseConnectionInfo() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const enablePublicAccess =
|
||||
!!data?.config?.postgres?.resources?.enablePublicAccess;
|
||||
|
||||
const form = useForm<DatabasePublicAccessFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enablePublicAccess,
|
||||
},
|
||||
resolver: yupResolver(databasePublicAccessValidationSchema),
|
||||
});
|
||||
|
||||
async function handleSubmit(formValues: DatabasePublicAccessFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: {
|
||||
enablePublicAccess: formValues.enablePublicAccess,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfigPromise;
|
||||
form.reset(formValues);
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Database settings are being updated...',
|
||||
successMessage: 'Database settings have been updated successfully.',
|
||||
errorMessage:
|
||||
"An error occurred while trying to update the project's database settings.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -76,49 +156,72 @@ export default function DatabaseConnectionInfo() {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Connection Info"
|
||||
description="Connect directly to the Postgres database with this information."
|
||||
slotProps={{ footer: { className: 'hidden' } }}
|
||||
className="grid grid-cols-6 gap-4 pb-2"
|
||||
>
|
||||
{settingsDatabaseCustomInputs.map(
|
||||
({ name, label, className, value: inputValue }) => (
|
||||
<Input
|
||||
key={name}
|
||||
label={label}
|
||||
required
|
||||
disabled
|
||||
value={inputValue}
|
||||
className={className}
|
||||
slotProps={{ inputRoot: { className: '!pr-8 truncate' } }}
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
endAdornment={
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<Button
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(inputValue as string, `${label}`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
const { formState } = form;
|
||||
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
To connect to the Postgres database directly, generate a new password,
|
||||
securely save it, and then modify your connection string with the newly
|
||||
created password.
|
||||
</Alert>
|
||||
</SettingsContainer>
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Public access"
|
||||
description={
|
||||
enablePublicAccess
|
||||
? 'Connect directly to the Postgres database with this information.'
|
||||
: 'Enable public access to your Postgres database.'
|
||||
}
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-cols-6 gap-4 pb-2"
|
||||
switchId="enablePublicAccess"
|
||||
showSwitch
|
||||
>
|
||||
{enablePublicAccess && (
|
||||
<>
|
||||
{settingsDatabaseCustomInputs.map(
|
||||
({ name, label, className, value: inputValue }) => (
|
||||
<Input
|
||||
key={name}
|
||||
label={label}
|
||||
required
|
||||
disabled
|
||||
value={inputValue}
|
||||
className={className}
|
||||
slotProps={{ inputRoot: { className: '!pr-8 truncate' } }}
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
endAdornment={
|
||||
<InputAdornment
|
||||
position="end"
|
||||
className="absolute right-2"
|
||||
>
|
||||
<Button
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(inputValue as string, `${label}`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
To connect to the Postgres database directly, generate a new
|
||||
password, securely save it, and then modify your connection
|
||||
string with the newly created password.
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
storage {
|
||||
capacity
|
||||
}
|
||||
enablePublicAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useGetAnnouncementsQuery } from '@/utils/__generated__/graphql';
|
||||
import formatDistance from 'date-fns/formatDistance';
|
||||
|
||||
export default function Announcements() {
|
||||
const { data, loading, error } = useGetAnnouncementsQuery();
|
||||
const { data, loading, error } = useGetAnnouncementsQuery({
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const announcements = data?.announcements || [];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { OverviewDeployments } from '@/features/projects/overview/components/OverviewDeployments';
|
||||
import { OverviewDocumentation } from '@/features/projects/overview/components/OverviewDocumentation';
|
||||
import { OverviewMetrics } from '@/features/projects/overview/components/OverviewMetrics';
|
||||
import { OverviewProjectHealth } from '@/features/projects/overview/components/OverviewProjectHealth';
|
||||
import { OverviewProjectInfo } from '@/features/projects/overview/components/OverviewProjectInfo';
|
||||
import { OverviewRepository } from '@/features/projects/overview/components/OverviewRepository';
|
||||
import { OverviewTopBar } from '@/features/projects/overview/components/OverviewTopBar';
|
||||
@@ -92,6 +93,8 @@ export default function ApplicationLive({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-row content-start gap-8 lg:col-span-1 lg:gap-12">
|
||||
<OverviewProjectHealth />
|
||||
<Divider />
|
||||
<OverviewProjectInfo />
|
||||
<Divider />
|
||||
<OverviewRepository />
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useGetConfigRawJsonQuery,
|
||||
useReplaceConfigRawJsonMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import * as TOML from '@iarna/toml';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { bbedit } from '@uiw/codemirror-theme-bbedit';
|
||||
import { githubDark } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function TOMLEditor() {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [tomlCode, setTOMLCode] = useState('');
|
||||
const [previousTOMLCode, setPreviousTOMLCode] = useState(''); // used to revert changes
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false); // used to show loading spinner on save
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
// fetch the initial TOML code from the server
|
||||
const { data, loading } = useGetConfigRawJsonQuery({
|
||||
variables: {
|
||||
appID: currentProject?.id,
|
||||
},
|
||||
skip: !currentProject,
|
||||
});
|
||||
|
||||
const [saveConfigMutation] = useReplaceConfigRawJsonMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const removeTOMLIndentation = (tomlStr: string) => {
|
||||
const trimmedLines = tomlStr.split('\n').map((line) => line.trimStart());
|
||||
return trimmedLines.join('\n');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Load TOML code from the server on initial load
|
||||
if (!loading && data) {
|
||||
const jsonData = JSON.parse(data?.configRawJSON);
|
||||
const tomlStr = TOML.stringify(jsonData);
|
||||
const unindentedTOMLConfig = removeTOMLIndentation(tomlStr);
|
||||
setTOMLCode(unindentedTOMLConfig);
|
||||
setPreviousTOMLCode(unindentedTOMLConfig);
|
||||
}
|
||||
}, [loading, data]);
|
||||
|
||||
const onChange = useCallback((value: string) => {
|
||||
setTOMLCode(value);
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleRevert = () => {
|
||||
setTOMLCode(previousTOMLCode);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
let jsonEditedConfig;
|
||||
try {
|
||||
jsonEditedConfig = TOML.parse(tomlCode);
|
||||
} catch (error) {
|
||||
const toastStyle = getToastStyleProps();
|
||||
const { line, col } = error;
|
||||
let message = `An error occurred while parsing the TOML file. Please check the syntax.`;
|
||||
if (line !== undefined && col !== undefined) {
|
||||
message = `An error occurred while parsing the TOML file. Please check the syntax at line ${line}, column ${col}.`;
|
||||
}
|
||||
toast.error(message, {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
const rawJSONString = JSON.stringify(jsonEditedConfig);
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const {
|
||||
data: { replaceConfigRawJSON: updatedConfig },
|
||||
} = await saveConfigMutation({
|
||||
variables: {
|
||||
appID: currentProject?.id,
|
||||
rawJSON: rawJSONString,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedConfig) {
|
||||
const jsonUpdatedConfig = JSON.parse(updatedConfig);
|
||||
const updatedTOMLConfig = TOML.stringify(jsonUpdatedConfig);
|
||||
const unindentedTOMLConfig = removeTOMLIndentation(updatedTOMLConfig);
|
||||
setTOMLCode(unindentedTOMLConfig);
|
||||
setPreviousTOMLCode(unindentedTOMLConfig);
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
setIsDirty(false);
|
||||
setIsSaving(false);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Saving configuration...',
|
||||
successMessage: 'Configuration has been saved successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while saving configuration. Please try again.',
|
||||
onError: () => {
|
||||
setIsSaving(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col">
|
||||
<Box className="flex w-full flex-col space-y-2 border-b p-4">
|
||||
<Text className="font-semibold">Configuration Editor</Text>
|
||||
</Box>
|
||||
<Box className="h-full overflow-auto">
|
||||
{loading ? (
|
||||
<Box
|
||||
className="h-full w-full animate-pulse"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
/>
|
||||
) : (
|
||||
<CodeMirror
|
||||
value={tomlCode}
|
||||
height="100%"
|
||||
width="100%"
|
||||
theme={theme.palette.mode === 'light' ? bbedit : githubDark}
|
||||
basicSetup={{
|
||||
searchKeymap: false,
|
||||
}}
|
||||
extensions={[StreamLanguage.define(toml)]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box className="grid w-full grid-flow-col justify-end gap-3 place-self-end border-t-1 px-4 py-3 md:justify-between">
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={loading || !isDirty}
|
||||
onClick={handleRevert}
|
||||
color="secondary"
|
||||
>
|
||||
Revert changes
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !isDirty}
|
||||
loading={isSaving}
|
||||
className="justify-self-end"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TOMLEditor } from './TOMLEditor';
|
||||
@@ -108,7 +108,7 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
id: null,
|
||||
countryCode: null,
|
||||
city: null,
|
||||
awsName: null,
|
||||
name: null,
|
||||
domain: null,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -31,7 +31,7 @@ afterEach(() => {
|
||||
|
||||
const region: ProjectFragment['region'] = {
|
||||
id: '1',
|
||||
awsName: 'eu-west-1',
|
||||
name: 'eu-west-1',
|
||||
domain: 'nhost.run',
|
||||
city: 'Dublin',
|
||||
countryCode: 'IE',
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function generateAppServiceUrl(
|
||||
const constructedDomain = [
|
||||
subdomain,
|
||||
service,
|
||||
region?.awsName,
|
||||
region?.name,
|
||||
region?.domain || 'nhost.run',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function AuthDomain() {
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={auth_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function HasuraDomain() {
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={hasura_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function RunServicePortDomain({
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={runServicePortFQDN}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function ServerlessFunctionsDomain() {
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={functions_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function VerifyDomain({
|
||||
</div>
|
||||
{isPlatform ? (
|
||||
<Button
|
||||
disabled={loading || !hostname || isPlatform}
|
||||
disabled={loading || !hostname}
|
||||
onClick={handleVerifyDomain}
|
||||
className="mt-4 sm:absolute sm:bottom-0 sm:right-0 sm:mt-0"
|
||||
>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
|
||||
const systemEnvironmentVariables = [
|
||||
{ key: 'NHOST_SUBDOMAIN', value: currentProject.subdomain },
|
||||
{ key: 'NHOST_REGION', value: currentProject.region.awsName },
|
||||
{ key: 'NHOST_REGION', value: currentProject.region.name },
|
||||
{
|
||||
key: 'NHOST_HASURA_URL',
|
||||
value:
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { ServicesOutlinedIcon } from '@/components/ui/v2/icons/ServicesOutlinedIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { OverviewProjectHealthModal } from '@/features/projects/overview/components/OverviewProjectHealthModal';
|
||||
import { ProjectHealthCard } from '@/features/projects/overview/components/ProjectHealthCard';
|
||||
import { ServiceVersionTooltip } from '@/features/projects/overview/components/ServiceVersionTooltip';
|
||||
import {
|
||||
baseServices,
|
||||
findHighestImportanceState,
|
||||
serviceStateToThemeColor,
|
||||
type ServiceHealthInfo,
|
||||
} from '@/features/projects/overview/health';
|
||||
import {
|
||||
ServiceState,
|
||||
useGetConfiguredVersionsQuery,
|
||||
useGetProjectServicesHealthQuery,
|
||||
useGetRecommendedSoftwareVersionsQuery,
|
||||
} from '@/generated/graphql';
|
||||
|
||||
interface RunStatusTooltipProps {
|
||||
servicesStatusInfo?: Array<ServiceHealthInfo>;
|
||||
openHealthModal?: (
|
||||
defaultExpanded?: keyof typeof baseServices | 'run',
|
||||
) => void;
|
||||
}
|
||||
|
||||
function RunStatusTooltip({
|
||||
servicesStatusInfo,
|
||||
openHealthModal,
|
||||
}: RunStatusTooltipProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 px-2 py-3">
|
||||
<ol className="m-0 flex flex-col gap-3">
|
||||
{servicesStatusInfo.map((service) => (
|
||||
<li
|
||||
key={service.name}
|
||||
className="flex flex-row items-center gap-4 text-ellipsis text-nowrap leading-5"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(service.state),
|
||||
}}
|
||||
className={`h-3 w-3 flex-shrink-0 rounded-full ${
|
||||
service.state === ServiceState.Updating ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<Text
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
{service.name}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button variant="outlined" onClick={() => openHealthModal('run')}>
|
||||
View state
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewProjectHealth() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { data: recommendedVersionsData, loading: loadingRecommendedVersions } =
|
||||
useGetRecommendedSoftwareVersionsQuery({
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const { data: configuredVersionsData, loading: loadingConfiguredVersions } =
|
||||
useGetConfiguredVersionsQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
|
||||
const {
|
||||
data: projectServicesHealthData,
|
||||
loading: loadingProjectServicesHealth,
|
||||
} = useGetProjectServicesHealthQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
|
||||
if (
|
||||
loadingRecommendedVersions ||
|
||||
loadingConfiguredVersions ||
|
||||
loadingProjectServicesHealth
|
||||
) {
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
<Text variant="h3">Project Health</Text>
|
||||
<div className="flex flex-row flex-wrap items-center justify-start gap-2 lg:gap-2">
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<UserIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<DatabaseIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<StorageIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<HasuraIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAIServiceEnabled = !!configuredVersionsData?.config?.ai;
|
||||
|
||||
const getRecommendedVersions = (softwareName: string): string[] =>
|
||||
recommendedVersionsData?.softwareVersions.reduce(
|
||||
(recommendedVersions, service) => {
|
||||
if (service.software === softwareName) {
|
||||
recommendedVersions.push(service.version);
|
||||
}
|
||||
return recommendedVersions;
|
||||
},
|
||||
[],
|
||||
) ?? [];
|
||||
|
||||
const authRecommendedVersions = getRecommendedVersions(
|
||||
baseServices['hasura-auth'].softwareVersionsName,
|
||||
);
|
||||
const hasuraRecommendedVersions = getRecommendedVersions(
|
||||
baseServices.hasura.softwareVersionsName,
|
||||
);
|
||||
const postgresRecommendedVersions = getRecommendedVersions(
|
||||
baseServices.postgres.softwareVersionsName,
|
||||
);
|
||||
const storageRecommendedVersions = getRecommendedVersions(
|
||||
baseServices['hasura-storage'].softwareVersionsName,
|
||||
);
|
||||
const aiRecommendedVersions = getRecommendedVersions(
|
||||
baseServices.ai.softwareVersionsName,
|
||||
);
|
||||
|
||||
// Check if configured version can't be found in recommended versions
|
||||
const isAuthVersionMismatch = !authRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.auth?.version === version,
|
||||
);
|
||||
|
||||
const isHasuraVersionMismatch = !hasuraRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.hasura?.version === version,
|
||||
);
|
||||
|
||||
const isPostgresVersionMismatch = !postgresRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.postgres?.version === version,
|
||||
);
|
||||
|
||||
const isStorageVersionMismatch = !storageRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.storage?.version === version,
|
||||
);
|
||||
|
||||
const isAIVersionMismatch = !aiRecommendedVersions.find(
|
||||
(version) => configuredVersionsData?.config?.ai?.version === version,
|
||||
);
|
||||
|
||||
const serviceMap: { [key: string]: ServiceHealthInfo | undefined } = {};
|
||||
projectServicesHealthData?.getProjectStatus?.services.forEach((service) => {
|
||||
serviceMap[service.name] = service;
|
||||
});
|
||||
const {
|
||||
'hasura-auth': authStatus,
|
||||
'hasura-storage': storageStatus,
|
||||
postgres: postgresStatus,
|
||||
hasura: hasuraStatus,
|
||||
ai: aiStatus,
|
||||
...otherServicesStatus
|
||||
} = serviceMap;
|
||||
|
||||
const openHealthModal = async (
|
||||
defaultExpanded: keyof typeof baseServices | 'run',
|
||||
) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<OverviewProjectHealthModal
|
||||
servicesHealth={projectServicesHealthData}
|
||||
defaultExpanded={defaultExpanded}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-2xl w-full' },
|
||||
titleProps: {
|
||||
onClose: closeDialog,
|
||||
},
|
||||
},
|
||||
title: 'Service State',
|
||||
});
|
||||
};
|
||||
|
||||
const authTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices['hasura-auth'].displayName}
|
||||
serviceKey="hasura-auth"
|
||||
usedVersion={configuredVersionsData?.config?.auth?.version ?? ''}
|
||||
recommendedVersionMismatch={isAuthVersionMismatch}
|
||||
recommendedVersions={authRecommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={authStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const hasuraTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.hasura.displayName}
|
||||
serviceKey="hasura"
|
||||
usedVersion={configuredVersionsData?.config?.hasura?.version ?? ''}
|
||||
recommendedVersionMismatch={isHasuraVersionMismatch}
|
||||
recommendedVersions={hasuraRecommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={hasuraStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const postgresTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.postgres.displayName}
|
||||
serviceKey="postgres"
|
||||
usedVersion={configuredVersionsData?.config?.postgres?.version ?? ''}
|
||||
recommendedVersionMismatch={isPostgresVersionMismatch}
|
||||
recommendedVersions={postgresRecommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={postgresStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const storageTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices['hasura-storage'].displayName}
|
||||
serviceKey="hasura-storage"
|
||||
usedVersion={configuredVersionsData?.config?.storage?.version ?? ''}
|
||||
recommendedVersionMismatch={isStorageVersionMismatch}
|
||||
recommendedVersions={storageRecommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={storageStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const aiTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.ai.displayName}
|
||||
serviceKey="ai"
|
||||
usedVersion={configuredVersionsData?.config?.ai?.version ?? ''}
|
||||
recommendedVersionMismatch={isAIVersionMismatch}
|
||||
recommendedVersions={aiRecommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={aiStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const runServices = Object.values(otherServicesStatus).filter((service) =>
|
||||
service.name.startsWith('run-'),
|
||||
);
|
||||
|
||||
const runServicesStates = runServices.map((service) => service.state);
|
||||
|
||||
const runServicesState = findHighestImportanceState(runServicesStates);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
<Text variant="h3">Project Health</Text>
|
||||
|
||||
{currentProject && (
|
||||
<div className="flex flex-row flex-wrap items-center justify-start gap-2 lg:gap-2">
|
||||
<ProjectHealthCard
|
||||
icon={<UserIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={authTooltipElem}
|
||||
isVersionMismatch={isAuthVersionMismatch}
|
||||
state={authStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<DatabaseIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={postgresTooltipElem}
|
||||
isVersionMismatch={isPostgresVersionMismatch}
|
||||
state={postgresStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<StorageIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={storageTooltipElem}
|
||||
isVersionMismatch={isStorageVersionMismatch}
|
||||
state={storageStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<HasuraIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={hasuraTooltipElem}
|
||||
isVersionMismatch={isHasuraVersionMismatch}
|
||||
state={hasuraStatus?.state}
|
||||
/>
|
||||
{isAIServiceEnabled && (
|
||||
<ProjectHealthCard
|
||||
icon={<AIIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={aiTooltipElem}
|
||||
isVersionMismatch={isAIVersionMismatch}
|
||||
state={aiStatus?.state}
|
||||
/>
|
||||
)}
|
||||
{Object.values(runServices).length > 0 && (
|
||||
<ProjectHealthCard
|
||||
icon={<ServicesOutlinedIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={
|
||||
<RunStatusTooltip
|
||||
servicesStatusInfo={Object.values(runServices)}
|
||||
openHealthModal={openHealthModal}
|
||||
/>
|
||||
}
|
||||
state={runServicesState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OverviewProjectHealth } from './OverviewProjectHealth';
|
||||
@@ -0,0 +1,301 @@
|
||||
import { CodeBlock } from '@/components/presentational/CodeBlock';
|
||||
import { Accordion } from '@/components/ui/v2/Accordion';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { ServicesOutlinedIcon } from '@/components/ui/v2/icons/ServicesOutlinedIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import {
|
||||
findHighestImportanceState,
|
||||
serviceStateToThemeColor,
|
||||
type baseServices,
|
||||
type ServiceHealthInfo,
|
||||
} from '@/features/projects/overview/health';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
ServiceState,
|
||||
type GetProjectServicesHealthQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import Image from 'next/image';
|
||||
import { type ReactElement } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface ServiceAccordionProps {
|
||||
serviceName: string;
|
||||
serviceHealth: ServiceHealthInfo;
|
||||
replicas: ServiceHealthInfo['replicas'];
|
||||
serviceState: ServiceState;
|
||||
/**
|
||||
* Icon to display on the accordion.
|
||||
*/
|
||||
icon?: string | ReactElement;
|
||||
/**
|
||||
* Label of the icon.
|
||||
*/
|
||||
alt?: string;
|
||||
iconIsComponent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
function ServiceAccordion({
|
||||
serviceName,
|
||||
serviceHealth,
|
||||
replicas,
|
||||
serviceState,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
alt,
|
||||
defaultExpanded = false,
|
||||
}: ServiceAccordionProps) {
|
||||
const replicasLabel = replicas.length === 1 ? 'replica' : 'replicas';
|
||||
|
||||
const serviceInfo = removeTypename(serviceHealth);
|
||||
|
||||
return (
|
||||
<Accordion.Root defaultExpanded={defaultExpanded}>
|
||||
<Accordion.Summary
|
||||
expandIcon={
|
||||
<ChevronDownIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
className="px-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && <Image src={icon} alt={alt} />}
|
||||
<Text
|
||||
sx={{ color: 'text.primary' }}
|
||||
variant="h4"
|
||||
className="font-semibold"
|
||||
>
|
||||
{serviceName}{' '}
|
||||
<Text
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
component="span"
|
||||
className="font-semibold"
|
||||
>
|
||||
({replicas.length} {replicasLabel})
|
||||
</Text>
|
||||
</Text>
|
||||
{serviceState === ServiceState.Running ? (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="flex h-2 w-2 items-center justify-center rounded-full"
|
||||
>
|
||||
<CheckIcon className="h-3/4 w-3/4 stroke-2 text-white" />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="h-2 w-2 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Summary>
|
||||
<Accordion.Details>
|
||||
<CodeBlock copyToClipboardToastTitle={`${serviceName} status`}>
|
||||
{JSON.stringify(serviceInfo, null, 2)}
|
||||
</CodeBlock>
|
||||
</Accordion.Details>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunServicesAccordionProps {
|
||||
servicesHealth: Array<ServiceHealthInfo>;
|
||||
serviceStates: ServiceState[];
|
||||
/**
|
||||
* Icon to display on the accordion.
|
||||
*/
|
||||
icon?: string | ReactElement;
|
||||
/**
|
||||
* Label of the icon.
|
||||
*/
|
||||
alt?: string;
|
||||
iconIsComponent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
function RunServicesAccordion({
|
||||
serviceStates,
|
||||
servicesHealth,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
defaultExpanded = false,
|
||||
alt,
|
||||
}: RunServicesAccordionProps) {
|
||||
const globalState = findHighestImportanceState(serviceStates);
|
||||
|
||||
const serviceInfo = removeTypename(servicesHealth);
|
||||
|
||||
return (
|
||||
<Accordion.Root defaultExpanded={defaultExpanded}>
|
||||
<Accordion.Summary
|
||||
expandIcon={
|
||||
<ChevronDownIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
className="px-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && <Image src={icon} alt={alt} />}
|
||||
<Text
|
||||
sx={{ color: 'text.primary' }}
|
||||
variant="h4"
|
||||
className="font-semibold"
|
||||
>
|
||||
Run
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(globalState),
|
||||
}}
|
||||
className="h-2 w-2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Summary>
|
||||
<Accordion.Details>
|
||||
<CodeBlock copyToClipboardToastTitle="Run services status">
|
||||
{JSON.stringify(serviceInfo, null, 2)}
|
||||
</CodeBlock>
|
||||
</Accordion.Details>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OverviewProjectHealthModalProps {
|
||||
servicesHealth?: GetProjectServicesHealthQuery;
|
||||
defaultExpanded?: keyof typeof baseServices | 'run';
|
||||
}
|
||||
|
||||
export default function OverviewProjectHealthModal({
|
||||
servicesHealth,
|
||||
defaultExpanded,
|
||||
}: OverviewProjectHealthModalProps) {
|
||||
const serviceMap: { [key: string]: ServiceHealthInfo | undefined } = {};
|
||||
servicesHealth.getProjectStatus.services.forEach((service) => {
|
||||
serviceMap[service.name] = service;
|
||||
});
|
||||
const {
|
||||
'hasura-auth': auth,
|
||||
'hasura-storage': storage,
|
||||
postgres,
|
||||
hasura,
|
||||
ai,
|
||||
...otherServices
|
||||
} = serviceMap;
|
||||
|
||||
const runServices = Object.values(otherServices).filter((service) =>
|
||||
service.name.startsWith('run-'),
|
||||
);
|
||||
|
||||
const isAuthExpandedByDefault = defaultExpanded === 'hasura-auth';
|
||||
const isPostgresExpandedByDefault = defaultExpanded === 'postgres';
|
||||
const isStorageExpandedByDefault = defaultExpanded === 'hasura-storage';
|
||||
const isHasuraExpandedByDefault = defaultExpanded === 'hasura';
|
||||
const isAIExpandedByDefault = defaultExpanded === 'ai';
|
||||
const isRunExpandedByDefault = defaultExpanded === 'run';
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg pt-2 text-left')}>
|
||||
<Box
|
||||
sx={{
|
||||
borderColor: 'text.dark',
|
||||
}}
|
||||
className="grid grid-flow-row"
|
||||
>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
serviceName="Auth"
|
||||
serviceHealth={auth}
|
||||
replicas={auth?.replicas}
|
||||
serviceState={auth?.state}
|
||||
defaultExpanded={isAuthExpandedByDefault}
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<DatabaseIcon className="h-4 w-4" />}
|
||||
serviceName="Postgres"
|
||||
serviceHealth={postgres}
|
||||
replicas={postgres?.replicas}
|
||||
serviceState={postgres?.state}
|
||||
defaultExpanded={isPostgresExpandedByDefault}
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<StorageIcon className="h-4 w-4" />}
|
||||
serviceName="Storage"
|
||||
serviceHealth={storage}
|
||||
replicas={storage?.replicas}
|
||||
serviceState={storage?.state}
|
||||
defaultExpanded={isStorageExpandedByDefault}
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<HasuraIcon className="h-4 w-4" />}
|
||||
serviceName="Hasura"
|
||||
serviceHealth={hasura}
|
||||
replicas={hasura?.replicas}
|
||||
serviceState={hasura?.state}
|
||||
defaultExpanded={isHasuraExpandedByDefault}
|
||||
/>
|
||||
{ai ? (
|
||||
<>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<AIIcon className="h-4 w-4" />}
|
||||
serviceName="AI"
|
||||
serviceHealth={ai}
|
||||
replicas={ai.replicas}
|
||||
serviceState={ai.state}
|
||||
defaultExpanded={isAIExpandedByDefault}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{Object.values(runServices).length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<RunServicesAccordion
|
||||
servicesHealth={Object.values(runServices)}
|
||||
icon={<ServicesOutlinedIcon className="h-4 w-4" />}
|
||||
serviceStates={Object.values(runServices).map(
|
||||
(service) => service.state,
|
||||
)}
|
||||
defaultExpanded={isRunExpandedByDefault}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OverviewProjectHealthModal } from './OverviewProjectHealthModal';
|
||||
@@ -7,7 +7,7 @@ export default function OverviewProjectInfo() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { region, subdomain } = currentProject || {};
|
||||
const isRegionAvailable =
|
||||
region?.awsName && region?.countryCode && region?.city;
|
||||
region?.name && region?.countryCode && region?.city;
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
@@ -17,7 +17,7 @@ export default function OverviewProjectInfo() {
|
||||
<div className="grid grid-flow-row gap-3">
|
||||
<InfoCard
|
||||
title="Region"
|
||||
value={region?.awsName}
|
||||
value={region?.name}
|
||||
customValue={
|
||||
region?.countryCode &&
|
||||
region?.city && (
|
||||
@@ -30,7 +30,7 @@ export default function OverviewProjectInfo() {
|
||||
/>
|
||||
|
||||
<Text className="truncate text-sm font-medium">
|
||||
{region.city} ({region.awsName})
|
||||
{region.city} ({region.name})
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/v2/Badge';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ExclamationFilledIcon } from '@/components/ui/v2/icons/ExclamationFilledIcon';
|
||||
import { Tooltip, tooltipClasses } from '@/components/ui/v2/Tooltip';
|
||||
import { serviceStateToBadgeColor } from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
import type { ImageProps } from 'next/image';
|
||||
import Image from 'next/image';
|
||||
import type { ReactElement } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface HealthBadgeProps extends BadgeProps {
|
||||
badgeVariant?: 'standard' | 'dot';
|
||||
badgeColor?: 'success' | 'error' | 'warning';
|
||||
showExclamation?: boolean;
|
||||
showCheckIcon?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function HealthBadge({
|
||||
badgeColor,
|
||||
badgeVariant,
|
||||
showExclamation,
|
||||
showCheckIcon,
|
||||
children,
|
||||
...props
|
||||
}: HealthBadgeProps) {
|
||||
if (!badgeColor) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
if (showExclamation) {
|
||||
return (
|
||||
<Badge
|
||||
variant="standard"
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
badgeContent={
|
||||
<ExclamationFilledIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.600',
|
||||
}}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={
|
||||
showCheckIcon ? (
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'text.primary',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={
|
||||
showCheckIcon ? (
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProjectHealthCardProps extends BoxProps {
|
||||
/**
|
||||
* Label of the card icon.
|
||||
*/
|
||||
alt?: string;
|
||||
/**
|
||||
* Tooltip of the card.
|
||||
*/
|
||||
tooltip?: ReactElement | null;
|
||||
/**
|
||||
* Icon to display on the card.
|
||||
*/
|
||||
icon: string | ReactElement;
|
||||
/**
|
||||
* Light version of the icon. This is used for the dark mode.
|
||||
*/
|
||||
lightIcon?: string | ReactElement;
|
||||
/**
|
||||
* Determines whether the icon should have a background.
|
||||
* @default false
|
||||
*/
|
||||
disableIconBackground?: boolean;
|
||||
/**
|
||||
* Determines whether the icon is a react component.
|
||||
* @default true
|
||||
*/
|
||||
iconIsComponent?: boolean;
|
||||
/**
|
||||
* Props to be passed to the internal components.
|
||||
*/
|
||||
slotProps?: {
|
||||
imgIcon?: Partial<ImageProps>;
|
||||
};
|
||||
/**
|
||||
* State of the service.
|
||||
*/
|
||||
state?: ServiceState;
|
||||
|
||||
/**
|
||||
* Determines whether the version is mismatched with recommended version.
|
||||
*/
|
||||
isVersionMismatch?: boolean;
|
||||
|
||||
/**
|
||||
* Determines whether the card is loading.
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectHealthCard({
|
||||
alt,
|
||||
tooltip,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
className,
|
||||
slotProps = {},
|
||||
isVersionMismatch = false,
|
||||
isLoading = false,
|
||||
state,
|
||||
...props
|
||||
}: ProjectHealthCardProps) {
|
||||
const badgeColor = serviceStateToBadgeColor.get(state);
|
||||
const badgeVariant = state === ServiceState.Running ? 'standard' : 'dot';
|
||||
const showCheckIcon = state === ServiceState.Running;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltip}
|
||||
slotProps={{
|
||||
popper: {
|
||||
sx: {
|
||||
[`&.${tooltipClasses.popper} .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.100' : 'grey.200',
|
||||
minWidth: '18rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className={twMerge(
|
||||
'grid aspect-square min-w-12 max-w-14 grid-flow-row gap-0 rounded-md p-0',
|
||||
className,
|
||||
)}
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-flow-col items-center justify-center">
|
||||
<HealthBadge
|
||||
badgeColor={!isLoading ? badgeColor : undefined}
|
||||
badgeVariant={badgeVariant}
|
||||
showCheckIcon={showCheckIcon}
|
||||
showExclamation={isVersionMismatch}
|
||||
>
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && (
|
||||
<Image
|
||||
src={icon}
|
||||
alt={alt}
|
||||
width={slotProps.imgIcon?.width}
|
||||
height={slotProps.imgIcon?.height}
|
||||
{...slotProps.imgIcon}
|
||||
/>
|
||||
)}
|
||||
</HealthBadge>
|
||||
</div>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user