-
-Changing cloud project's configuration:
-
-
-
-Querying cloud project's configuration:
-
-
-
-Querying local project's schema:
-
-
-
-Generating code from local project's schema:
-
-
-
-Resulting code:
-
-
-
-Querying local project's configuration:
-
-
-
-Modifying local project's configuration:
-
-
-
-Querying cloud project's schema:
-
-
-
-Querying cloud project's data:
-
-
-
-Managing cloud project's data:
-
-
-
-Analysing cloud project's data:
-
-
diff --git a/cli/docs/mcp/screenshots/101-cloud-projects.png b/cli/docs/mcp/screenshots/101-cloud-projects.png
deleted file mode 100644
index a256ad9c1..000000000
Binary files a/cli/docs/mcp/screenshots/101-cloud-projects.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/102-cloud-project-config.png b/cli/docs/mcp/screenshots/102-cloud-project-config.png
deleted file mode 100644
index ad071b8ab..000000000
Binary files a/cli/docs/mcp/screenshots/102-cloud-project-config.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/103-cloud-project-config2.png b/cli/docs/mcp/screenshots/103-cloud-project-config2.png
deleted file mode 100644
index 1f4955307..000000000
Binary files a/cli/docs/mcp/screenshots/103-cloud-project-config2.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/201-local-schema.png b/cli/docs/mcp/screenshots/201-local-schema.png
deleted file mode 100644
index 746faef22..000000000
Binary files a/cli/docs/mcp/screenshots/201-local-schema.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/202-local-code.png b/cli/docs/mcp/screenshots/202-local-code.png
deleted file mode 100644
index 1471276d1..000000000
Binary files a/cli/docs/mcp/screenshots/202-local-code.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/203-result.png b/cli/docs/mcp/screenshots/203-result.png
deleted file mode 100644
index acb4559c3..000000000
Binary files a/cli/docs/mcp/screenshots/203-result.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/204-local-config-query.png b/cli/docs/mcp/screenshots/204-local-config-query.png
deleted file mode 100644
index 2af9fe3d1..000000000
Binary files a/cli/docs/mcp/screenshots/204-local-config-query.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/205-local-config-change.png b/cli/docs/mcp/screenshots/205-local-config-change.png
deleted file mode 100644
index 7262ac0a0..000000000
Binary files a/cli/docs/mcp/screenshots/205-local-config-change.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/301-project-schema.png b/cli/docs/mcp/screenshots/301-project-schema.png
deleted file mode 100644
index 5c26187a7..000000000
Binary files a/cli/docs/mcp/screenshots/301-project-schema.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/302-project-query.png b/cli/docs/mcp/screenshots/302-project-query.png
deleted file mode 100644
index da76dac97..000000000
Binary files a/cli/docs/mcp/screenshots/302-project-query.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/303-project-mutation.png b/cli/docs/mcp/screenshots/303-project-mutation.png
deleted file mode 100644
index 1ffa42756..000000000
Binary files a/cli/docs/mcp/screenshots/303-project-mutation.png and /dev/null differ
diff --git a/cli/docs/mcp/screenshots/304-project-data-analysis.png b/cli/docs/mcp/screenshots/304-project-data-analysis.png
deleted file mode 100644
index a1d49b9f8..000000000
Binary files a/cli/docs/mcp/screenshots/304-project-data-analysis.png and /dev/null differ
diff --git a/cli/examples/myproject/nhost/nhost.toml b/cli/examples/myproject/nhost/nhost.toml
index 3372ce75a..3d6946cec 100644
--- a/cli/examples/myproject/nhost/nhost.toml
+++ b/cli/examples/myproject/nhost/nhost.toml
@@ -3,6 +3,10 @@
name = 'GREET'
value = 'Sayonara'
+[[global.environment]]
+name = 'NODE_ENV'
+value = 'production'
+
[hasura]
version = 'v2.46.0-ce'
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
@@ -63,11 +67,6 @@ default = "00000000-0000-0000-0000-000000000000"
expiresIn = 2592000
[auth.method]
-[auth.method.anonymous]
-enabled = false
-
-[auth.method.emailPasswordless]
-enabled = false
[auth.method.emailPassword]
hibpEnabled = false
@@ -139,46 +138,11 @@ version = '14.18-20250728-1'
[postgres.resources.storage]
capacity = 1
-[postgres.settings]
-maxConnections = 100
-sharedBuffers = '256MB'
-effectiveCacheSize = '768MB'
-maintenanceWorkMem = '64MB'
-checkpointCompletionTarget = 0.9
-walBuffers = '-1'
-defaultStatisticsTarget = 100
-randomPageCost = 1.1
-effectiveIOConcurrency = 200
-workMem = '1310kB'
-hugePages = 'off'
-minWalSize = '80MB'
-maxWalSize = '1GB'
-maxWorkerProcesses = 8
-maxParallelWorkersPerGather = 2
-maxParallelWorkers = 8
-maxParallelMaintenanceWorkers = 2
-
[provider]
[storage]
version = '0.7.1'
-[ai]
-version = '0.8.0'
-webhookSecret = '{{ secrets.GRAPHITE_WEBHOOK_SECRET }}'
-
-[ai.resources]
-[ai.resources.compute]
-cpu = 125
-memory = 256
-
-[ai.openai]
-organization = ''
-apiKey = '{{ secrets.OPENAI_API_KEY }}'
-
-[ai.autoEmbeddings]
-synchPeriodMinutes = 5
-
[observability]
[observability.grafana]
adminPassword = '{{ secrets.GRAFANA_ADMIN_PASSWORD }}'
diff --git a/cli/get.sh b/cli/get.sh
index 0275ba8c0..2af1b57c0 100755
--- a/cli/get.sh
+++ b/cli/get.sh
@@ -44,7 +44,7 @@ if [[ "$version" == "latest" ]]; then
release=$(curl --silent https://api.github.com/repos/nhost/nhost/releases\?per_page=100 | grep tag_name | grep \"cli\@ | head -n 1 | sed 's/.*"tag_name": "\([^"]*\)".*/\1/')
version=$( echo $release | sed 's/.*@//')
else
- release="cli@$release"
+ release="cli@$version"
fi
# check version exists
diff --git a/cli/mcp/config/config.go b/cli/mcp/config/config.go
index d9d7143a3..98461a354 100644
--- a/cli/mcp/config/config.go
+++ b/cli/mcp/config/config.go
@@ -1,58 +1,78 @@
package config
import (
+ "context"
"errors"
"fmt"
+ "net/http"
"os"
"path/filepath"
+ "strings"
+ "github.com/nhost/nhost/cli/clienv"
+ "github.com/nhost/nhost/cli/mcp/nhost/auth"
"github.com/pelletier/go-toml/v2"
+ "github.com/urfave/cli/v3"
)
-func ptr[T any](v T) *T {
- return &v
-}
-
const (
DefaultLocalConfigServerURL = "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
DefaultLocalGraphqlURL = "https://local.graphql.local.nhost.run/v1"
)
+var ErrProjectNotConfigured = errors.New("project not configured")
+
type Config struct {
// If configured allows managing the cloud. For instance, this allows you to configure
// projects, list projects, organizations, and so on.
Cloud *Cloud `json:"cloud,omitempty" toml:"cloud"`
- // If configured allows working with a local project running via the CLI. This includes
- // configuring it, working with the schema, migrations, etc.
- Local *Local `json:"local,omitempty" toml:"local"`
-
- // Projects is a list of projects that you want to allow access to. This grants access to the
- // GraphQL schema allowing it to inspect it and run allowed queries and mutations.
- Projects []Project `json:"projects" toml:"projects"`
+ // Projects is a list of projects that you want to allow access to. This grants access
+ // to the GraphQL schema allowing it to inspect it and run allowed queries and mutations.
+ Projects ProjectList `json:"projects" toml:"projects"`
}
type Cloud struct {
- // Personal Access Token to authenticate with the Nhost Cloud API. You can get one
- // on the following URL: https://app.nhost.io/account
- PAT string `json:"pat" toml:"pat"`
-
// If enabled you can run mutations against the Nhost Cloud to manipulate project's configurations
// amongst other things. Queries are always allowed if this section is configured.
EnableMutations bool `json:"enable_mutations" toml:"enable_mutations"`
}
-type Local struct {
- // Admin secret to use when running against a local project.
- AdminSecret string `json:"admin_secret" toml:"admin_secret"`
+type ProjectList []Project
- // GraphQL URL to use when running against a local project.
- // Defaults to "https://local.dashboard.local.nhost.run/v1/configserver/graphql"
- ConfigServerURL *string `json:"config_server_url,omitempty" toml:"config_server_url,omitempty"`
+func (pl ProjectList) Get(subdomain string) (*Project, error) {
+ for _, p := range pl {
+ if p.Subdomain == subdomain {
+ return &p, nil
+ }
+ }
- // GraphQL URL to use when running against a local project.
- // Defaults to "https://local.graphql.local.nhost.run/v1"
- GraphqlURL *string `json:"graphql_url,omitempty" toml:"graphql_url,omitempty"`
+ return nil, fmt.Errorf("%w: %s", ErrProjectNotConfigured, subdomain)
+}
+
+func (pl ProjectList) Subdomains() []string {
+ subdomains := make([]string, 0, len(pl))
+
+ for _, p := range pl {
+ subdomains = append(subdomains, p.Subdomain)
+ }
+
+ return subdomains
+}
+
+func (pl ProjectList) Instructions() string {
+ if len(pl) == 0 {
+ return "No projects configured. Please, run `nhost mcp config` to configure your projects."
+ }
+
+ var sb strings.Builder
+ sb.WriteString("Configured projects:\n")
+
+ for _, p := range pl {
+ sb.WriteString(fmt.Sprintf("- %s (%s): %s\n", p.Subdomain, p.Region, p.Description))
+ }
+
+ return sb.String()
}
type Project struct {
@@ -62,6 +82,9 @@ type Project struct {
// Project's region
Region string `json:"region" toml:"region"`
+ // Project's description
+ Description string `json:"description,omitempty" toml:"description,omitempty"`
+
// Admin secret to operate against the project.
// Either admin secret or PAT is required.
AdminSecret *string `json:"admin_secret,omitempty" toml:"admin_secret,omitempty"`
@@ -70,6 +93,10 @@ type Project struct {
// Either admin secret or PAT is required.
PAT *string `json:"pat,omitempty" toml:"pat,omitempty"`
+ // If enabled, allows managing the project's metadata (tables, relationships,
+ // permissions, etc).
+ ManageMetadata bool `json:"manage_metadata,omitempty" toml:"manage_metadata,omitempty"`
+
// List of queries that are allowed to be executed against the project.
// If empty, no queries are allowed. Use [*] to allow all queries.
AllowQueries []string `json:"allow_queries" toml:"allow_queries"`
@@ -78,30 +105,80 @@ type Project struct {
// If empty, no mutations are allowed. Use [*] to allow all mutations.
// Note that this is only used if the project is configured to allow mutations.
AllowMutations []string `json:"allow_mutations" toml:"allow_mutations"`
+
+ // GraphQL URL to use when running against the project. Defaults to constructed URL with
+ // the subdomain and region.
+ GraphqlURL string `json:"graphql_url,omitzero" toml:"graphql_url,omitzero"`
+
+ // Auth URL to use when running against the project. Defaults to constructed URL with
+ // the subdomain and region.
+ AuthURL string `json:"auth_url,omitzero" toml:"auth_url,omitzero"`
+
+ // Hasura's base URL. Defaults to constructed URL with the subdomain and region.
+ HasuraURL string `json:"hasura_url,omitzero" toml:"hasura_url,omitzero"`
}
-func GetConfigPath() string {
- configHome := os.Getenv("XDG_CONFIG_HOME")
- if configHome == "" {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- return "mcp-nhost.toml"
- }
-
- configHome = filepath.Join(homeDir, ".config")
+func (p *Project) GetAuthURL() string {
+ if p.AuthURL != "" {
+ return p.AuthURL
}
- return filepath.Join(configHome, "nhost", "mcp-nhost.toml")
+ return fmt.Sprintf("https://%s.auth.%s.nhost.run/v1", p.Subdomain, p.Region)
+}
+
+func (p *Project) GetGraphqlURL() string {
+ if p.GraphqlURL != "" {
+ return p.GraphqlURL
+ }
+
+ return fmt.Sprintf("https://%s.graphql.%s.nhost.run/v1", p.Subdomain, p.Region)
+}
+
+func (p *Project) GetHasuraURL() string {
+ if p.HasuraURL != "" {
+ return p.HasuraURL
+ }
+
+ return fmt.Sprintf("https://%s.hasura.%s.nhost.run", p.Subdomain, p.Region)
+}
+
+func (p *Project) GetAuthInterceptor() (func(ctx context.Context, req *http.Request) error, error) {
+ if p.AdminSecret != nil {
+ return auth.WithAdminSecret(*p.AdminSecret), nil
+ } else if p.PAT != nil {
+ interceptor, err := auth.WithPAT(p.GetAuthURL(), *p.PAT)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create PAT interceptor: %w", err)
+ }
+
+ return interceptor, nil
+ }
+
+ return func(_ context.Context, _ *http.Request) error {
+ return nil
+ }, nil
+}
+
+func GetConfigPath(cmd *cli.Command) string {
+ configPath := cmd.String("config-file")
+ if configPath != "" {
+ return configPath
+ }
+
+ ce := clienv.FromCLI(cmd)
+
+ return filepath.Join(ce.Path.DotNhostFolder(), "mcp-nhost.toml")
}
func Load(path string) (*Config, error) {
- f, err := os.OpenFile(path, os.O_RDONLY, 0o600) //nolint:mnd
+ content, err := os.ReadFile(path)
if err != nil {
- return nil, fmt.Errorf("failed to open config file: %w", err)
+ return nil, fmt.Errorf("failed to read config file: %w", err)
}
- defer f.Close()
- decoder := toml.NewDecoder(f)
+ interpolated := interpolateEnv(string(content), os.Getenv)
+
+ decoder := toml.NewDecoder(strings.NewReader(interpolated))
decoder.DisallowUnknownFields()
var config Config
@@ -120,15 +197,5 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
}
- if config.Local != nil {
- if config.Local.GraphqlURL == nil {
- config.Local.GraphqlURL = ptr(DefaultLocalGraphqlURL)
- }
-
- if config.Local.ConfigServerURL == nil {
- config.Local.ConfigServerURL = ptr(DefaultLocalConfigServerURL)
- }
- }
-
return &config, nil
}
diff --git a/cli/mcp/config/interpolate.go b/cli/mcp/config/interpolate.go
new file mode 100644
index 000000000..b6830306c
--- /dev/null
+++ b/cli/mcp/config/interpolate.go
@@ -0,0 +1,60 @@
+package config
+
+import "strings"
+
+// interpolateEnv replaces environment variables in the format $VAR.
+// Supports escaping $ with $$ or \$.
+func interpolateEnv(s string, getenv func(string) string) string { //nolint:cyclop
+ var result strings.Builder
+ result.Grow(len(s))
+
+ for i := 0; i < len(s); i++ {
+ switch {
+ case s[i] == '\\' && i+1 < len(s) && s[i+1] == '$':
+ // Handle \$ escape sequence
+ result.WriteByte('$')
+
+ i++ // skip the $
+ case s[i] == '$' && i+1 < len(s) && s[i+1] == '$':
+ // Handle $$ escape sequence
+ result.WriteByte('$')
+
+ i++ // skip the second $
+ case s[i] == '$':
+ // Start of variable substitution
+ i++
+ if i >= len(s) {
+ result.WriteByte('$')
+ break
+ }
+
+ // Extract variable name
+ start := i
+ for i < len(s) && (isAlphaNumUnderscore(s[i])) {
+ i++
+ }
+
+ if i == start {
+ // No valid variable name found
+ result.WriteByte('$')
+
+ i--
+ } else {
+ varName := s[start:i]
+ if value := getenv(varName); value != "" {
+ result.WriteString(value)
+ }
+
+ i-- // Back up one because the loop will increment
+ }
+ default:
+ result.WriteByte(s[i])
+ }
+ }
+
+ return result.String()
+}
+
+func isAlphaNumUnderscore(c byte) bool {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
+}
diff --git a/cli/mcp/config/interpolate_test.go b/cli/mcp/config/interpolate_test.go
new file mode 100644
index 000000000..f4d537db3
--- /dev/null
+++ b/cli/mcp/config/interpolate_test.go
@@ -0,0 +1,256 @@
+package config //nolint:testpackage
+
+import (
+ "os"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestInterpolateEnv(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ name: "simple variable substitution",
+ input: "admin_secret = \"$SECRET\"",
+ envVars: map[string]string{"SECRET": "mysecret"},
+ expected: "admin_secret = \"mysecret\"",
+ },
+ {
+ name: "multiple variables",
+ input: "$VAR1 and $VAR2",
+ envVars: map[string]string{"VAR1": "hello", "VAR2": "world"},
+ expected: "hello and world",
+ },
+ {
+ name: "variable with underscores",
+ input: "$MY_VAR_123",
+ envVars: map[string]string{"MY_VAR_123": "value"},
+ expected: "value",
+ },
+ {
+ name: "escaped with $$",
+ input: "price = $$100",
+ envVars: map[string]string{},
+ expected: "price = $100",
+ },
+ {
+ name: "escaped with backslash",
+ input: "price = \\$100",
+ envVars: map[string]string{},
+ expected: "price = $100",
+ },
+ {
+ name: "mix of escaped and variable",
+ input: "$$SECRET is $SECRET",
+ envVars: map[string]string{"SECRET": "hidden"},
+ expected: "$SECRET is hidden",
+ },
+ {
+ name: "undefined variable",
+ input: "value = $UNDEFINED",
+ envVars: map[string]string{},
+ expected: "value = ",
+ },
+ {
+ name: "variable at end",
+ input: "end$VAR",
+ envVars: map[string]string{"VAR": "value"},
+ expected: "endvalue",
+ },
+ {
+ name: "dollar sign alone at end",
+ input: "end$",
+ envVars: map[string]string{},
+ expected: "end$",
+ },
+ {
+ name: "dollar sign with non-alphanum",
+ input: "$ hello",
+ envVars: map[string]string{},
+ expected: "$ hello",
+ },
+ {
+ name: "no variables",
+ input: "plain text without variables",
+ envVars: map[string]string{},
+ expected: "plain text without variables",
+ },
+ {
+ name: "empty string",
+ input: "",
+ envVars: map[string]string{},
+ expected: "",
+ },
+ {
+ name: "multiple escapes in a row",
+ input: "$$$$",
+ envVars: map[string]string{},
+ expected: "$$",
+ },
+ {
+ name: "variable surrounded by text",
+ input: "prefix$VAR suffix",
+ envVars: map[string]string{"VAR": "middle"},
+ expected: "prefixmiddle suffix",
+ },
+ {
+ name: "backslash escape followed by variable",
+ input: "\\$100 costs $PRICE",
+ envVars: map[string]string{"PRICE": "$50"},
+ expected: "$100 costs $50",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Create isolated getenv function
+ getenv := func(key string) string {
+ return tt.envVars[key]
+ }
+
+ result := interpolateEnv(tt.input, getenv)
+ if result != tt.expected {
+ t.Errorf("interpolateEnv() = %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestInterpolateEnvRealWorld(t *testing.T) {
+ t.Parallel()
+
+ envVars := map[string]string{
+ "ADMIN_SECRET": "super-secret-key",
+ "SUBDOMAIN": "myapp",
+ }
+ getenv := func(key string) string {
+ return envVars[key]
+ }
+
+ input := `[[projects]]
+subdomain = "local"
+region = "local"
+admin_secret = "$ADMIN_SECRET"
+
+[[projects]]
+subdomain = "$SUBDOMAIN"
+admin_secret = "$ADMIN_SECRET"
+# Price is $$100
+`
+
+ expected := `[[projects]]
+subdomain = "local"
+region = "local"
+admin_secret = "super-secret-key"
+
+[[projects]]
+subdomain = "myapp"
+admin_secret = "super-secret-key"
+# Price is $100
+`
+
+ result := interpolateEnv(input, getenv)
+ if result != expected {
+ t.Errorf("interpolateEnv() = %q, want %q", result, expected)
+ }
+}
+
+func TestIsAlphaNumUnderscore(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ char byte
+ expected bool
+ }{
+ {'a', true},
+ {'z', true},
+ {'A', true},
+ {'Z', true},
+ {'0', true},
+ {'9', true},
+ {'_', true},
+ {'-', false},
+ {'.', false},
+ {'$', false},
+ {' ', false},
+ {'/', false},
+ }
+
+ for _, tt := range tests {
+ t.Run(string(tt.char), func(t *testing.T) {
+ t.Parallel()
+
+ result := isAlphaNumUnderscore(tt.char)
+ if result != tt.expected {
+ t.Errorf("isAlphaNumUnderscore(%q) = %v, want %v", tt.char, result, tt.expected)
+ }
+ })
+ }
+}
+
+func ptr[T any](v T) *T {
+ return &v
+}
+
+func TestLoadWithInterpolation(t *testing.T) {
+ // Create a temporary config file
+ content := `[[projects]]
+admin_secret = "$TEST_ADMIN_SECRET"
+
+[[projects]]
+subdomain = "myapp"
+region = "us-east-1"
+admin_secret = "$TEST_PROJECT_SECRET"
+allow_queries = ["*"]
+`
+
+ tmpfile, err := os.CreateTemp(t.TempDir(), "config-*.toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(tmpfile.Name())
+
+ if _, err := tmpfile.WriteString(content); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := tmpfile.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Set environment variables
+ t.Setenv("TEST_ADMIN_SECRET", "local-secret")
+ t.Setenv("TEST_PROJECT_SECRET", "project-secret")
+
+ // Load config
+ cfg, err := Load(tmpfile.Name())
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if diff := cmp.Diff(cfg, &Config{
+ Cloud: nil,
+ Projects: ProjectList{
+ { //nolint:exhaustruct
+ AdminSecret: ptr("local-secret"),
+ },
+ { //nolint:exhaustruct
+ Subdomain: "myapp",
+ Region: "us-east-1",
+ AdminSecret: ptr("project-secret"),
+ AllowQueries: []string{"*"},
+ },
+ },
+ }); diff != "" {
+ t.Errorf("diff = %s", diff)
+ }
+}
diff --git a/cli/mcp/config/wizard.go b/cli/mcp/config/wizard.go
index 6d3a6db19..231680c3e 100644
--- a/cli/mcp/config/wizard.go
+++ b/cli/mcp/config/wizard.go
@@ -25,11 +25,14 @@ func RunWizard() (*Config, error) {
projects := wizardProject(reader)
+ if localConfig != nil {
+ projects = append(projects, *localConfig)
+ }
+
fmt.Println()
return &Config{
Cloud: cloudConfig,
- Local: localConfig,
Projects: projects,
}, nil
}
@@ -41,13 +44,9 @@ func wizardCloud(reader *bufio.Reader) *Cloud {
fmt.Println(" You can view and configure projects as you would in the dashboard.")
if promptYesNo(reader, "Enable Nhost Cloud access?") {
- pat := promptString(
- reader,
- "Enter Personal Access Token (from https://app.nhost.io/account):",
- )
+ fmt.Println(" Note: If you haven't already, run `nhost login` to authenticate.")
return &Cloud{
- PAT: pat,
EnableMutations: true,
}
}
@@ -56,7 +55,7 @@ func wizardCloud(reader *bufio.Reader) *Cloud {
}
//nolint:forbidigo
-func wizardLocal(reader *bufio.Reader) *Local {
+func wizardLocal(reader *bufio.Reader) *Project {
fmt.Println("2. Local Development Access")
fmt.Println(" This allows LLMs to interact with your local Nhost environment,")
fmt.Println(" including project configuration and GraphQL API access.")
@@ -68,10 +67,18 @@ func wizardLocal(reader *bufio.Reader) *Local {
adminSecret = "nhost-admin-secret" //nolint:gosec
}
- return &Local{
- AdminSecret: adminSecret,
- ConfigServerURL: nil,
- GraphqlURL: nil,
+ return &Project{
+ Subdomain: "local",
+ Region: "local",
+ Description: "Local development project running via the Nhost CLI",
+ AdminSecret: &adminSecret,
+ PAT: nil,
+ ManageMetadata: true,
+ AllowQueries: []string{"*"},
+ AllowMutations: []string{"*"},
+ AuthURL: "",
+ GraphqlURL: "",
+ HasuraURL: "",
}
}
@@ -95,16 +102,29 @@ func wizardProject(reader *bufio.Reader) []Project {
if promptYesNo(reader, "Configure project access?") {
for {
project := Project{
+ Description: "",
Subdomain: "",
Region: "",
AdminSecret: nil,
PAT: nil,
+ ManageMetadata: false,
AllowQueries: []string{"*"},
AllowMutations: []string{"*"},
+ GraphqlURL: "",
+ AuthURL: "",
+ HasuraURL: "",
}
project.Subdomain = promptString(reader, "Project subdomain:")
project.Region = promptString(reader, "Project region:")
+ project.Description = promptString(
+ reader,
+ "Project description to provide additional information to LLMs:",
+ )
+ project.ManageMetadata = promptYesNo(
+ reader,
+ "Allow managing metadata (tables, relationships, permissions, etc)?",
+ )
authType := promptChoice(
reader,
diff --git a/cli/mcp/graphql/parse.go b/cli/mcp/graphql/parse.go
index f6bf3b2c5..b05c9ac95 100644
--- a/cli/mcp/graphql/parse.go
+++ b/cli/mcp/graphql/parse.go
@@ -1,6 +1,7 @@
package graphql
import (
+ "encoding/json"
"fmt"
"sort"
"strings"
@@ -30,7 +31,7 @@ func getTypeName(t Type) string {
}
// ParseSchema converts an introspection query result into a GraphQL SDL string.
-func ParseSchema(response ResponseIntrospection, filter Filter) string {
+func ParseSchema(response ResponseIntrospection, filter Filter) string { //nolint:cyclop
availableTypes := make(map[string]Type)
// Process all types in the schema
@@ -58,6 +59,9 @@ func ParseSchema(response ResponseIntrospection, filter Filter) string {
}
neededMutations := make(map[string]Field)
+ if response.Data.Schema.MutationType == nil {
+ return render(neededQueries, neededMutations, neededTypes)
+ }
for _, mutation := range response.Data.Schema.MutationType.Fields {
if filter.AllowMutations == nil {
@@ -83,6 +87,30 @@ func ParseSchema(response ResponseIntrospection, filter Filter) string {
return render(neededQueries, neededMutations, neededTypes)
}
+func SummarizeSchema(response ResponseIntrospection) string {
+ summary := map[string][]string{
+ "query": make([]string, len(response.Data.Schema.QueryType.Fields)),
+ }
+
+ for i, query := range response.Data.Schema.QueryType.Fields {
+ summary["query"][i] = query.Name
+ }
+
+ if response.Data.Schema.MutationType != nil {
+ summary["mutation"] = make([]string, len(response.Data.Schema.MutationType.Fields))
+ for _, mutation := range response.Data.Schema.MutationType.Fields {
+ summary["mutation"] = append(summary["mutation"], mutation.Name)
+ }
+ }
+
+ b, err := json.MarshalIndent(summary, "", " ")
+ if err != nil {
+ return fmt.Sprintf("failed to marshal summary: %v", err)
+ }
+
+ return string(b)
+}
+
func filterNestedArgs(
args []InputValue, neededTypes map[string]Type,
) []InputValue {
diff --git a/cli/mcp/graphql/query.go b/cli/mcp/graphql/query.go
index 55ef6d8ff..db2553ecd 100644
--- a/cli/mcp/graphql/query.go
+++ b/cli/mcp/graphql/query.go
@@ -24,6 +24,10 @@ func checkAllowedOperation(
selectionSet ast.SelectionSet,
allowed []string,
) error {
+ if slices.Contains(allowed, "*") {
+ return nil
+ }
+
for _, v := range selectionSet {
if v, ok := v.(*ast.Field); ok {
if len(v.SelectionSet) > 0 && !slices.Contains(allowed, v.Name) {
@@ -45,8 +49,8 @@ func CheckAllowedGraphqlQuery( //nolint:cyclop
queryString string,
) error {
if allowedQueries == nil && allowedMutations == nil {
- // nil means unrestricted
- return nil
+ // nil means nothing allowed
+ return fmt.Errorf("%w: %s", ErrQueryNotAllowed, queryString)
}
if len(allowedQueries) == 0 && len(allowedMutations) == 0 {
diff --git a/cli/mcp/graphql/query_test.go b/cli/mcp/graphql/query_test.go
index cb78344d1..a592b05e8 100644
--- a/cli/mcp/graphql/query_test.go
+++ b/cli/mcp/graphql/query_test.go
@@ -22,7 +22,21 @@ func TestCheckAllowedGraphqlQuery(t *testing.T) {
query: `query { user(id: 1) { name } }`,
allowedQueries: nil,
allowedMutations: nil,
- expectedError: nil,
+ expectedError: graphql.ErrQueryNotAllowed,
+ },
+ {
+ name: "nil,",
+ query: `query { user(id: 1) { name } }`,
+ allowedQueries: nil,
+ allowedMutations: []string{"user"},
+ expectedError: graphql.ErrQueryNotAllowed,
+ },
+ {
+ name: ",nil",
+ query: `mutation { user(id: 1) { name } }`,
+ allowedQueries: []string{"user"},
+ allowedMutations: nil,
+ expectedError: graphql.ErrQueryNotAllowed,
},
{
name: "no query allowed",
diff --git a/cli/mcp/nhost/auth/auth.gen.go b/cli/mcp/nhost/auth/auth.gen.go
index e09ef0f4c..c15414907 100644
--- a/cli/mcp/nhost/auth/auth.gen.go
+++ b/cli/mcp/nhost/auth/auth.gen.go
@@ -1,6 +1,6 @@
// Package auth provides primitives to interact with the openapi HTTP API.
//
-// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package auth
import (
diff --git a/cli/mcp/nhost/graphql/graphql.gen.go b/cli/mcp/nhost/graphql/graphql.gen.go
index 4caa93db7..47218e2bf 100644
--- a/cli/mcp/nhost/graphql/graphql.gen.go
+++ b/cli/mcp/nhost/graphql/graphql.gen.go
@@ -1,6 +1,6 @@
// Package graphql provides primitives to interact with the openapi HTTP API.
//
-// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package graphql
import (
diff --git a/cli/mcp/resources/cloud.go b/cli/mcp/resources/cloud.go
new file mode 100644
index 000000000..01c92445d
--- /dev/null
+++ b/cli/mcp/resources/cloud.go
@@ -0,0 +1,69 @@
+package resources
+
+import (
+ "context"
+ _ "embed"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/nhost/nhost/cli/mcp/config"
+)
+
+//go:embed cloud_schema.graphql
+var schemaGraphql string
+
+//go:embed cloud_schema-with-mutations.graphql
+var schemaGraphqlWithMutations string
+
+const (
+ CloudResourceURI = "schema://nhost-cloud"
+ CloudDescription = `Schema to interact with the Nhost Cloud. Projects are equivalent
+to apps in the schema. IDs are typically uuids.`
+)
+
+type Cloud struct {
+ schema string
+}
+
+func NewCloud(cfg *config.Config) *Cloud {
+ schema := schemaGraphql
+ if cfg.Cloud.EnableMutations {
+ schema = schemaGraphqlWithMutations
+ }
+
+ return &Cloud{
+ schema: schema,
+ }
+}
+
+func (t *Cloud) Register(server *server.MCPServer) {
+ server.AddResource(
+ mcp.Resource{
+ URI: CloudResourceURI,
+ Name: "nhost-cloud",
+ Annotated: mcp.Annotated{
+ Annotations: &mcp.Annotations{
+ Audience: []mcp.Role{"agent"},
+ Priority: 9.0, //nolint:mnd
+ },
+ },
+ Description: CloudDescription,
+ MIMEType: "text/plain",
+ Meta: nil,
+ },
+ t.handle,
+ )
+}
+
+func (t *Cloud) handle(
+ _ context.Context, request mcp.ReadResourceRequest,
+) ([]mcp.ResourceContents, error) {
+ return []mcp.ResourceContents{
+ mcp.TextResourceContents{
+ URI: request.Params.URI,
+ MIMEType: "text/plain",
+ Text: t.schema,
+ Meta: nil,
+ },
+ }, nil
+}
diff --git a/cli/mcp/tools/cloud/schema-with-mutations.graphql b/cli/mcp/resources/cloud_schema-with-mutations.graphql
similarity index 100%
rename from cli/mcp/tools/cloud/schema-with-mutations.graphql
rename to cli/mcp/resources/cloud_schema-with-mutations.graphql
diff --git a/cli/mcp/tools/cloud/schema.graphql b/cli/mcp/resources/cloud_schema.graphql
similarity index 100%
rename from cli/mcp/tools/cloud/schema.graphql
rename to cli/mcp/resources/cloud_schema.graphql
diff --git a/cli/mcp/resources/graphql_management_schema.go b/cli/mcp/resources/graphql_management_schema.go
new file mode 100644
index 000000000..dd690a763
--- /dev/null
+++ b/cli/mcp/resources/graphql_management_schema.go
@@ -0,0 +1,54 @@
+package resources
+
+import (
+ "context"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/nhost/nhost/cli/mcp/nhost/graphql"
+)
+
+const (
+ GraphqlManagementResourceURI = "schema://graphql-management"
+ GraphqlManagementDescription = `GraphQL's management schema for an Nhost project.
+This tool is useful to properly understand how manage hasura metadata, migrations,
+permissions, remote schemas, etc.`
+)
+
+type GraphqlManagement struct{}
+
+func NewGraphqlManagement() *GraphqlManagement {
+ return &GraphqlManagement{}
+}
+
+func (t *GraphqlManagement) Register(server *server.MCPServer) {
+ server.AddResource(
+ mcp.Resource{
+ URI: GraphqlManagementResourceURI,
+ Name: "graphql-management",
+ Annotated: mcp.Annotated{
+ Annotations: &mcp.Annotations{
+ Audience: []mcp.Role{"agent"},
+ Priority: 9.0, //nolint:mnd
+ },
+ },
+ Description: GraphqlManagementDescription,
+ MIMEType: "text/plain",
+ Meta: nil,
+ },
+ t.handle,
+ )
+}
+
+func (t *GraphqlManagement) handle(
+ _ context.Context, request mcp.ReadResourceRequest,
+) ([]mcp.ResourceContents, error) {
+ return []mcp.ResourceContents{
+ mcp.TextResourceContents{
+ URI: request.Params.URI,
+ MIMEType: "text/plain",
+ Text: graphql.Schema,
+ Meta: nil,
+ },
+ }, nil
+}
diff --git a/cli/mcp/resources/nhost_toml.go b/cli/mcp/resources/nhost_toml.go
new file mode 100644
index 000000000..c832803c1
--- /dev/null
+++ b/cli/mcp/resources/nhost_toml.go
@@ -0,0 +1,57 @@
+package resources
+
+import (
+ "context"
+ _ "embed"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+//go:embed nhost_toml_schema.cue
+var schemaNhostToml string
+
+const (
+ NhostTomlResourceURI = "schema://nhost.toml"
+ NhostTomlResourceDescription = `Cuelang schema for the nhost.toml configuration file. Run nhost
+config validate after making changes to your nhost.toml file to ensure it is valid.`
+)
+
+type NhostToml struct{}
+
+func NewNhostToml() *NhostToml {
+ return &NhostToml{}
+}
+
+func (t *NhostToml) Register(server *server.MCPServer) {
+ server.AddResource(
+ mcp.Resource{
+ URI: NhostTomlResourceURI,
+ Name: "nhost.toml",
+ Annotated: mcp.Annotated{
+ Annotations: &mcp.Annotations{
+ Audience: []mcp.Role{"agent"},
+ Priority: 9.0, //nolint:mnd
+ },
+ },
+ Description: NhostTomlResourceDescription,
+ MIMEType: "text/plain",
+ Meta: nil,
+ },
+ t.handle,
+ )
+}
+
+//go:generate cp ../../../vendor/github.com/nhost/be/services/mimir/schema/schema.cue nhost_toml_schema.cue
+func (t *NhostToml) handle(
+ _ context.Context, request mcp.ReadResourceRequest,
+) ([]mcp.ResourceContents, error) {
+ return []mcp.ResourceContents{
+ mcp.TextResourceContents{
+ URI: request.Params.URI,
+ MIMEType: "text/plain",
+ Text: schemaNhostToml,
+ Meta: nil,
+ },
+ }, nil
+}
diff --git a/cli/mcp/resources/nhost_toml_schema.cue b/cli/mcp/resources/nhost_toml_schema.cue
new file mode 100644
index 000000000..e751881b6
--- /dev/null
+++ b/cli/mcp/resources/nhost_toml_schema.cue
@@ -0,0 +1,812 @@
+package schema
+
+import (
+ "list"
+ "math"
+ "net"
+ "strings"
+ "time"
+)
+
+// main entrypoint to the configuration
+#Config: {
+ // Global configuration that applies to all services
+ global: #Global
+
+ // Configuration for hasura
+ hasura: #Hasura
+
+ // Advanced configuration for GraphQL
+ graphql?: #Graphql
+
+ // Configuration for functions service
+ functions: #Functions
+
+ // Configuration for auth service
+ auth: #Auth
+
+ // Configuration for postgres service
+ postgres: #Postgres
+
+ // Configuration for third party providers like SMTP, SMS, etc.
+ provider: #Provider
+
+ // Configuration for storage service
+ storage: #Storage
+
+ // Configuration for graphite service
+ ai?: #AI
+
+ // Configuration for observability service
+ observability: #Observability
+
+ _totalResourcesCPU: (
+ hasura.resources.replicas*hasura.resources.compute.cpu +
+ auth.resources.replicas*auth.resources.compute.cpu +
+ storage.resources.replicas*storage.resources.compute.cpu +
+ postgres.resources.compute.cpu) @cuegraph(skip)
+
+ _totalResourcesMemory: (
+ hasura.resources.replicas*hasura.resources.compute.memory +
+ auth.resources.replicas*auth.resources.compute.memory +
+ storage.resources.replicas*storage.resources.compute.memory +
+ postgres.resources.compute.memory) @cuegraph(skip)
+
+ _validateResourcesTotalCpuMemoryRatioMustBe1For2: (
+ _totalResourcesCPU*2.048 & _totalResourcesMemory*1.0) @cuegraph(skip)
+
+ _validateResourcesTotalCpuMin1000: (
+ hasura.resources.compute.cpu+
+ auth.resources.compute.cpu+
+ storage.resources.compute.cpu+
+ postgres.resources.compute.cpu) >= 1000 & true @cuegraph(skip)
+
+ _validateAllResourcesAreSetOrNot: (
+ ((hasura.resources.compute != _|_) == (auth.resources.compute != _|_)) &&
+ ((auth.resources.compute != _|_) == (storage.resources.compute != _|_)) &&
+ ((storage.resources.compute != _|_) == (postgres.resources.compute != _|_))) & true @cuegraph(skip)
+
+ _validateNetworkingMustBeNullOrNotSet: !storage.resources.networking | storage.resources.networking == null @cuegraph(skip)
+
+ _isProviderSMTPSet: provider.smtp != _|_ @cuegraph(skip)
+ _isAuthRateLimitEmailsDefault: auth.rateLimit.emails.limit == 10 && auth.rateLimit.emails.interval == "1h" @cuegraph(skip)
+ _validateAuthRateLimitEmailsIsDefaultOrSMTPSettingsSet: (_isProviderSMTPSet | _isAuthRateLimitEmailsDefault) & true @cuegraph(skip)
+}
+
+// Global configuration that applies to all services
+#Global: {
+ // User-defined environment variables that are spread over all services
+ environment: [...#GlobalEnvironmentVariable] | *[]
+}
+
+#GlobalEnvironmentVariable: {
+ // Name of the environment variable
+ name: =~"(?i)^[a-z_]{1,}[a-z0-9_]*" & !~"(?i)^NHOST_" & !~"(?i)^HASURA_"
+ // Value of the environment variable
+ value: string
+}
+
+#Graphql: {
+ security: #GraphqlSecurity
+}
+
+#GraphqlSecurity: {
+ forbidAminSecret: bool | *false
+ maxDepthQueries: uint | *0 // 0 disables the check
+}
+
+#Networking: {
+ ingresses: [#Ingress] | *[]
+}
+
+#Ingress: {
+ fqdn: [string & net.FQDN & strings.MinRunes(1) & strings.MaxRunes(63)]
+
+ tls?: {
+ clientCA?: string
+ }
+}
+
+#Autoscaler: {
+ maxReplicas: uint8 & >=2 & <=100
+}
+
+// Resource configuration for a service
+#Resources: {
+ compute?: #ResourcesCompute
+
+ // Number of replicas for a service
+ replicas: uint8 & >=1 & <=10 | *1
+ autoscaler?: #Autoscaler
+
+ _validateReplicasMustBeSmallerThanMaxReplicas: (replicas <= autoscaler.maxReplicas) & true @cuegraph(skip)
+
+ _validateMultipleReplicasNeedsCompute: (
+ replicas == 1 && autoscaler == _|_ |
+ compute != _|_) & true @cuegraph(skip)
+ _validateMultipleReplicasRatioMustBe1For2: (
+ replicas == 1 && autoscaler == _|_ |
+ (compute.cpu*2.048 == compute.memory)) & true @cuegraph(skip)
+
+ networking?: #Networking | null
+}
+
+#ResourcesCompute: {
+ // milicpus, 1000 milicpus = 1 cpu
+ cpu: uint32 & >=250 & <=30000
+ // MiB: 128MiB to 30GiB
+ memory: uint32 & >=128 & <=62464
+
+ // validate CPU steps of 250 milicpus
+ _validateCPUSteps250: (mod(cpu, 250) == 0) & true @cuegraph(skip)
+
+ // validate memory steps of 128 MiB
+ _validateMemorySteps128: (mod(memory, 128) == 0) & true @cuegraph(skip)
+}
+
+// Configuration for hasura service
+#Hasura: {
+ // Version of hasura, you can see available versions in the URL below:
+ // https://hub.docker.com/r/hasura/graphql-engine/tags
+ version: string | *"v2.48.5-ce"
+
+ // JWT Secrets configuration
+ jwtSecrets: [#JWTSecret]
+
+ // Admin secret
+ adminSecret: string
+
+ // Webhook secret
+ webhookSecret: string
+
+ // Configuration for hasura services
+ // Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
+ settings: {
+ // HASURA_GRAPHQL_CORS_DOMAIN
+ corsDomain: [...#Url] | *["*"]
+ // HASURA_GRAPHQL_DEV_MODE
+ devMode: bool | *true
+ // HASURA_GRAPHQL_ENABLE_ALLOWLIST
+ enableAllowList: bool | *false
+ // HASURA_GRAPHQL_ENABLE_CONSOLE
+ enableConsole: bool | *true
+ // HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
+ enableRemoteSchemaPermissions: bool | *false
+ // HASURA_GRAPHQL_ENABLED_APIS
+ enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
+
+ // HASURA_GRAPHQL_INFER_FUNCTION_PERMISSIONS
+ inferFunctionPermissions: bool | *true
+
+ // HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
+ liveQueriesMultiplexedRefetchInterval: uint32 | *1000
+
+ // HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES
+ stringifyNumericTypes: bool | *false
+ }
+
+ authHook?: {
+ // HASURA_GRAPHQL_AUTH_HOOK
+ url: string
+
+ // HASURA_GRAPHQL_AUTH_HOOK_MODE
+ mode: "GET" | *"POST"
+
+ // HASURA_GRAPHQL_AUTH_HOOK_SEND_REQUEST_BODY
+ sendRequestBody: bool | *true
+ }
+
+ logs: {
+ // HASURA_GRAPHQL_LOG_LEVEL
+ level: "debug" | "info" | "error" | *"warn"
+ }
+
+ events: {
+ // HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
+ httpPoolSize: uint32 & >=1 & <=100 | *100
+ }
+
+ // Resources for the service
+ resources?: #Resources
+
+ rateLimit?: #RateLimit
+}
+
+// APIs for hasura
+#HasuraAPIs: "metadata" | "graphql" | "pgdump" | "config"
+
+// Configuration for storage service
+#Storage: {
+ // Version of storage service, you can see available versions in the URL below:
+ // https://hub.docker.com/r/nhost/hasura-storage/tags
+ //
+ // Releases:
+ //
+ // https://github.com/nhost/hasura-storage/releases
+ version: string | *"0.9.1"
+
+ // Networking (custom domains at the moment) are not allowed as we need to do further
+ // configurations in the CDN. We will enable it again in the future.
+ resources?: #Resources & {networking?: null}
+
+ antivirus?: {
+ server: "tcp://run-clamav:3310"
+ }
+
+ rateLimit?: #RateLimit
+}
+
+// Configuration for functions service
+#Functions: {
+ node: {
+ version: 20 | *22
+ }
+
+ resources?: {
+ networking?: #Networking
+ }
+
+ rateLimit?: #RateLimit
+}
+
+// Configuration for postgres service
+#Postgres: {
+ // Version of postgres, you can see available versions in the URL below:
+ // https://hub.docker.com/r/nhost/postgres/tags
+ version: string | *"14.18-20250728-1"
+
+ // Resources for the service
+ resources: {
+ compute?: #ResourcesCompute
+ storage: {
+ capacity: uint32 & >=1 & <=1000 // GiB
+ }
+
+ replicas?: 1
+
+ enablePublicAccess?: bool | *false
+ }
+
+ settings?: {
+ jit: "off" | "on" | *"on"
+ maxConnections: int32 | *100
+ sharedBuffers: string | *"128MB"
+ effectiveCacheSize: string | *"4GB"
+ maintenanceWorkMem: string | *"64MB"
+ checkpointCompletionTarget: number | *0.9
+ walBuffers: string | *"-1"
+ defaultStatisticsTarget: int32 | *100
+ randomPageCost: number | *4.0
+ effectiveIOConcurrency: int32 | *1
+ workMem: string | *"4MB"
+ hugePages: string | *"try"
+ minWalSize: string | *"80MB"
+ maxWalSize: string | *"1GB"
+ maxWorkerProcesses: int32 | *8
+ maxParallelWorkersPerGather: int32 | *2
+ maxParallelWorkers: int32 | *8
+ maxParallelMaintenanceWorkers: int32 | *2
+ walLevel: string | *"replica"
+ maxWalSenders: int32 | *10
+ maxReplicationSlots: int32 | *10
+ archiveTimeout: int32 & >=300 & <=1073741823 | *300
+ trackIoTiming: "on" | *"off"
+
+ // if pitr is on we need walLevel to set to replica or logical
+ _validateWalLevelIsLogicalOrReplicaIfPitrIsEnabled: ( pitr == _|_ | walLevel == "replica" | walLevel == "logical") & true @cuegraph(skip)
+ }
+
+ pitr?: {
+ retention: uint8 & 7
+ }
+}
+
+// Configuration for auth service
+// You can find more information about the configuration here:
+// https://github.com/nhost/hasura-auth/blob/main/docs/environment-variables.md
+#Auth: {
+ // Version of auth, you can see available versions in the URL below:
+ // https://hub.docker.com/r/nhost/hasura-auth/tags
+ //
+ // Releases:
+ //
+ // https://github.com/nhost/hasura-auth/releases
+ version: string | *"0.43.0"
+
+ // Resources for the service
+ resources?: #Resources
+
+ elevatedPrivileges: {
+ mode: "recommended" | "required" | *"disabled"
+ }
+
+ redirections: {
+ // AUTH_CLIENT_URL
+ clientUrl: #Url | *"http://localhost:3000"
+ // AUTH_ACCESS_CONTROL_ALLOWED_REDIRECT_URLS
+ allowedUrls: [...string]
+ }
+
+ signUp: {
+ // Inverse of AUTH_DISABLE_SIGNUP
+ enabled: bool | *true
+
+ // AUTH_DISABLE_NEW_USERS
+ disableNewUsers: bool | *false
+
+ turnstile?: {
+ secretKey: string
+ }
+ }
+
+ user: {
+ roles: {
+ // AUTH_USER_DEFAULT_ROLE
+ default: #UserRole | *"user"
+ // AUTH_USER_DEFAULT_ALLOWED_ROLES
+ allowed: [...#UserRole] | *[default, "me"]
+ }
+ locale: {
+ // AUTH_LOCALE_DEFAULT
+ default: #Locale | *"en"
+ // AUTH_LOCALE_ALLOWED_LOCALES
+ allowed: [...#Locale] | *[default]
+ }
+
+ gravatar: {
+ // AUTH_GRAVATAR_ENABLED
+ enabled: bool | *true
+ // AUTH_GRAVATAR_DEFAULT
+ default: "404" | "mp" | "identicon" | "monsterid" | "wavatar" | "retro" | "robohash" | *"blank"
+ // AUTH_GRAVATAR_RATING
+ rating: "pg" | "r" | "x" | *"g"
+ }
+ email: {
+ // AUTH_ACCESS_CONTROL_ALLOWED_EMAILS
+ allowed: [...#Email]
+ // AUTH_ACCESS_CONTROL_BLOCKED_EMAILS
+ blocked: [...#Email]
+
+ }
+ emailDomains: {
+ // AUTH_ACCESS_CONTROL_ALLOWED_EMAIL_DOMAINS
+ allowed: [...string & net.FQDN]
+ // AUTH_ACCESS_CONTROL_BLOCKED_EMAIL_DOMAINS
+ blocked: [...string & net.FQDN]
+ }
+ }
+
+ session: {
+ accessToken: {
+ // AUTH_ACCESS_TOKEN_EXPIRES_IN
+ expiresIn: uint32 | *900
+ // AUTH_JWT_CUSTOM_CLAIMS
+ customClaims: [...{
+ key: =~"[a-zA-Z_]{1,}[a-zA-Z0-9_]*"
+ value: string
+ default?: string
+ }] | *[]
+ }
+
+ refreshToken: {
+ // AUTH_REFRESH_TOKEN_EXPIRES_IN
+ expiresIn: uint32 | *2592000
+ }
+
+ }
+
+ method: {
+ anonymous: {
+ enabled: bool | *false
+ }
+
+ emailPasswordless: {
+ enabled: bool | *false
+ }
+
+ otp: {
+ email: {
+ enabled: bool | *false
+ }
+ }
+
+ emailPassword: {
+ // Disabling email+password sign in is not implmented yet
+ // enabled: bool | *true
+ hibpEnabled: bool | *false
+ emailVerificationRequired: bool | *true
+ passwordMinLength: uint8 & >=3 | *9
+ }
+
+ smsPasswordless: {
+ enabled: bool | *false
+ }
+
+ oauth: {
+ apple: {
+ enabled: bool | *false
+ if enabled {
+ clientId: string
+ keyId: string
+ teamId: string
+ privateKey: string
+ }
+ if !enabled {
+ clientId?: string
+ keyId?: string
+ teamId?: string
+ privateKey?: string
+ }
+ audience?: string
+ scope?: [...string]
+ }
+ azuread: {
+ #StandardOauthProvider
+ tenant: string | *"common"
+ }
+ bitbucket: #StandardOauthProvider
+ discord: #StandardOauthProviderWithScope
+ entraid: {
+ #StandardOauthProvider
+ tenant: string | *"common"
+ }
+ facebook: #StandardOauthProviderWithScope
+ github: #StandardOauthProviderWithScope
+ gitlab: #StandardOauthProviderWithScope
+ google: #StandardOauthProviderWithScope
+ linkedin: #StandardOauthProviderWithScope
+ spotify: #StandardOauthProviderWithScope
+ strava: #StandardOauthProviderWithScope
+ twitch: #StandardOauthProviderWithScope
+ twitter: {
+ enabled: bool | *false
+ if enabled {
+ consumerKey: string
+ consumerSecret: string
+ }
+ if !enabled {
+ consumerKey?: string
+ consumerSecret?: string
+ }
+ }
+ windowslive: #StandardOauthProviderWithScope
+ workos: {
+ #StandardOauthProvider
+ connection?: string
+ organization?: string
+ }
+ }
+
+ webauthn: {
+ enabled: bool | *false
+ relyingParty?: {
+ id: string | *""
+ name?: string
+ origins?: [...#Url] | *[redirections.clientUrl]
+ }
+ attestation: {
+ timeout: uint32 | *60000
+ }
+ }
+ }
+
+ totp: {
+ enabled: bool | *false
+ if enabled {
+ issuer: string
+ }
+ if !enabled {
+ issuer?: string
+ }
+ }
+
+ misc: {
+ concealErrors: bool | *false
+ }
+
+ rateLimit: #AuthRateLimit
+}
+
+#RateLimit: {
+ limit: uint32
+ interval: string & time.Duration
+}
+
+#AuthRateLimit: {
+ emails: #RateLimit | *{limit: 10, interval: "1h"}
+ sms: #RateLimit | *{limit: 10, interval: "1h"}
+ bruteForce: #RateLimit | *{limit: 10, interval: "5m"}
+ signups: #RateLimit | *{limit: 10, interval: "5m"}
+ global: #RateLimit | *{limit: 100, interval: "1m"}
+}
+
+#StandardOauthProvider: {
+ enabled: bool | *false
+ if enabled {
+ clientId: string
+ clientSecret: string
+ }
+ if !enabled {
+ clientId?: string
+ clientSecret?: string
+ }
+}
+
+#StandardOauthProviderWithScope: {
+ enabled: bool | *false
+ if enabled {
+ clientId: string
+ clientSecret: string
+ }
+ if !enabled {
+ clientId?: string
+ clientSecret?: string
+ }
+ audience?: string
+ scope?: [...string]
+}
+
+#Provider: {
+ smtp?: #Smtp
+ sms?: #Sms
+}
+
+#Smtp: {
+ user: string
+ password: string
+ sender: string
+ host: string & net.FQDN | net.IP
+ port: #Port
+ secure: bool
+ method: "LOGIN" | "CRAM-MD5" | "PLAIN"
+}
+
+#Sms: {
+ provider: "twilio"
+ accountSid: string
+ authToken: string
+ messagingServiceId: string
+}
+
+#UserRole: string
+#Url: string
+#Port: uint16
+#Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
+#Locale: string & strings.MinRunes(2) & strings.MaxRunes(2)
+
+// See https://hasura.io/docs/latest/auth/authentication/jwt/
+#JWTSecret:
+ ({
+ type: "HS384" | "HS512" | *"HS256"
+ key: string
+ } |
+ {
+ type: "RS256" | "RS384" | "RS512"
+ key: string
+ signingKey?: string
+ kid?: string
+ } |
+ {
+ jwk_url: #Url | *null
+ }) &
+ {
+ claims_format?: "stringified_json" | *"json"
+ audience?: string
+ issuer?: string
+ allowed_skew?: uint32
+ header?: string
+ } & {
+ claims_map?: [...#ClaimMap]
+
+ } &
+ ({
+ claims_namespace: string | *"https://hasura.io/jwt/claims"
+ } |
+ {
+ claims_namespace_path: string
+ } | *{})
+
+#ClaimMap: {
+ claim: string
+ {
+ value: string
+ } | {
+ path: string
+ default?: string
+ }
+} & {}
+
+#SystemConfig: {
+ auth: {
+ email: {
+ templates: {
+ s3Key?: string
+ }
+ }
+ }
+
+ graphql: {
+ // manually enable graphi on a per-service basis
+ // by default it follows the plan
+ featureAdvancedGraphql: bool | *false
+ }
+
+ postgres: {
+ enabled: bool | *true
+ majorVersion: "14" | "15" | "16" | "17" | *"14"
+ if enabled {
+ database: string
+ }
+ if !enabled {
+ database?: string
+ }
+ connectionString: {
+ backup: string
+ hasura: string
+ auth: string
+ storage: string
+ }
+
+ disk?: {
+ iops: uint32 | *3000
+ tput: uint32 | *125
+ }
+
+ encryptColumnKey?: string & =~"^[0-9a-fA-F]{64}$" // 32 bytes hex-encoded key
+ oldEncryptColumnKey?: string & =~"^[0-9a-fA-F]{64}$" // for key rotation
+ }
+
+ persistentVolumesEncrypted: bool | *false
+}
+
+#AI: {
+ version: string | *"0.8.0"
+ resources: {
+ compute: #ComputeResources
+ }
+
+ openai: {
+ organization?: string
+ apiKey: string
+ }
+
+ autoEmbeddings: {
+ synchPeriodMinutes: uint32 | *5
+ }
+
+ webhookSecret: string
+}
+
+#Observability: {
+ grafana: #Grafana
+}
+
+#Grafana: {
+ adminPassword: string
+
+ smtp?: {
+ host: string & net.FQDN | net.IP
+ port: #Port
+ sender: string
+ user: string
+ password: string
+ }
+
+ alerting: {
+ enabled: bool | *false
+ }
+
+ contacts: {
+ emails?: [...string]
+ pagerduty?: [...{
+ integrationKey: string
+ severity: string
+ class: string
+ component: string
+ group: string
+ }]
+ discord?: [...{
+ url: string
+ avatarUrl: string
+ }]
+ slack?: [...{
+ recipient: string
+ token: string
+ username: string
+ iconEmoji: string
+ iconURL: string
+ mentionUsers: [...string]
+ mentionGroups: [...string]
+ mentionChannel: string
+ url: string
+ endpointURL: string
+ }]
+ webhook?: [...{
+ url: string
+ httpMethod: string
+ username: string
+ password: string
+ authorizationScheme: string
+ authorizationCredentials: string
+ maxAlerts: int
+ }]
+
+ }
+}
+
+#RunServicePort: {
+ port: #Port
+ type: "http" | "grpc" | "tcp" | "udp"
+ publish: bool | *false
+ ingresses: [#Ingress] | *[]
+ _publish_supported_only_over_http: (
+ publish == false || type == "http" || type == "grpc" ) & true @cuegraph(skip)
+
+ rateLimit?: #RateLimit
+}
+
+#RunServiceName: =~"^[a-z]([-a-z0-9]*[a-z0-9])?$" & strings.MinRunes(1) & strings.MaxRunes(30)
+
+// Resource configuration for a service
+#ComputeResources: {
+ // milicpus, 1000 milicpus = 1 cpu
+ cpu: uint32 & >=62 & <=14000
+ // MiB: 128MiB to 30GiB
+ memory: uint32 & >=128 & <=28720
+
+ // validate memory steps of 128 MiB
+ _validateMemorySteps128: (mod(memory, 128) == 0) & true @cuegraph(skip)
+}
+
+// Resource configuration for a service
+#RunServiceResources: {
+ compute: #ComputeResources
+
+ storage: [...{
+ name: #RunServiceName // name of the volume, changing it will cause data loss
+ capacity: uint32 & >=1 & <=1000 // GiB
+ path: string
+ }] | *[]
+ _storage_name_must_be_unique: list.UniqueItems([for s in storage {s.name}]) & true @cuegraph(skip)
+ _storage_path_must_be_unique: list.UniqueItems([for s in storage {s.path}]) & true @cuegraph(skip)
+
+ // Number of replicas for a service
+ replicas: uint8 & <=10
+
+ autoscaler?: #Autoscaler
+
+ _validateReplicasMustBeSmallerThanMaxReplicas: (replicas <= autoscaler.maxReplicas) & true @cuegraph(skip)
+
+ _replcas_cant_be_greater_than_1_when_using_storage: (len(storage) == 0 | (len(storage) > 0 & replicas <= 1 && autoscaler == _|_)) & true @cuegraph(skip)
+
+ _validate_cpu_memory_ratio_must_be_1_for_2: (math.Abs(compute.memory-compute.cpu*2.048) <= 1.024) & true @cuegraph(skip)
+}
+
+#RunServiceImage: {
+ image: string
+ // content of "auths", i.e., { "auths": $THIS }
+ pullCredentials?: string
+}
+
+#HealthCheck: {
+ port: #Port
+ initialDelaySeconds: int | *30
+ probePeriodSeconds: int | *60
+}
+
+#EnvironmentVariable: {
+ // Name of the environment variable
+ name: =~"(?i)^[a-z_]{1,}[a-z0-9_]*"
+ // Value of the environment variable
+ value: string
+}
+
+#RunServiceConfig: {
+ name: #RunServiceName
+ image: #RunServiceImage
+ command: [...string]
+ environment: [...#EnvironmentVariable] | *[]
+ ports?: [...#RunServicePort] | *[]
+ resources: #RunServiceResources
+ healthCheck?: #HealthCheck
+}
diff --git a/cli/mcp/resources/resources.go b/cli/mcp/resources/resources.go
new file mode 100644
index 000000000..1d6739df7
--- /dev/null
+++ b/cli/mcp/resources/resources.go
@@ -0,0 +1,40 @@
+package resources
+
+import (
+ "fmt"
+
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/nhost/nhost/cli/mcp/config"
+)
+
+func Instructions() string {
+ return "The following resources are available:\n\n" +
+ fmt.Sprintf("- %s: %s\n", CloudResourceURI, CloudDescription) +
+ fmt.Sprintf("- %s: %s\n", GraphqlManagementResourceURI, GraphqlManagementDescription) +
+ fmt.Sprintf("- %s: %s\n", NhostTomlResourceURI, NhostTomlResourceDescription)
+}
+
+func Register(cfg *config.Config, server *server.MCPServer) error {
+ nt := NewNhostToml()
+ nt.Register(server)
+
+ if cfg.Cloud != nil {
+ ct := NewCloud(cfg)
+ ct.Register(server)
+ }
+
+ enableGraphlManagement := false
+ for _, project := range cfg.Projects {
+ if project.ManageMetadata {
+ enableGraphlManagement = true
+ break
+ }
+ }
+
+ if enableGraphlManagement {
+ gmt := NewGraphqlManagement()
+ gmt.Register(server)
+ }
+
+ return nil
+}
diff --git a/cli/mcp/tools/cloud/cloud.go b/cli/mcp/tools/cloud/cloud.go
index 07f83028b..0f2f6e643 100644
--- a/cli/mcp/tools/cloud/cloud.go
+++ b/cli/mcp/tools/cloud/cloud.go
@@ -2,18 +2,11 @@ package cloud
import (
"context"
- _ "embed"
"net/http"
"github.com/mark3labs/mcp-go/server"
)
-//go:embed schema.graphql
-var schemaGraphql string
-
-//go:embed schema-with-mutations.graphql
-var schemaGraphqlWithMutations string
-
type Tool struct {
graphqlURL string
withMutations bool
@@ -33,7 +26,6 @@ func NewTool(
}
func (t *Tool) Register(mcpServer *server.MCPServer) error {
- t.registerGetGraphqlSchema(mcpServer)
t.registerGraphqlQuery(mcpServer)
return nil
diff --git a/cli/mcp/tools/cloud/get_schema.go b/cli/mcp/tools/cloud/get_schema.go
deleted file mode 100644
index 44f8f61bc..000000000
--- a/cli/mcp/tools/cloud/get_schema.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package cloud
-
-import (
- "context"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
-)
-
-const (
- ToolGetGraphqlSchemaName = "cloud-get-graphql-schema"
- //nolint:lll
- ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for the Nhost Cloud allowing operations on projects and organizations. Retrieve the schema before using the tool to understand the available queries and mutations. Projects are equivalent to apps in the schema. IDs are typically uuids`
-)
-
-func (t *Tool) registerGetGraphqlSchema(mcpServer *server.MCPServer) {
- schemaTool := mcp.NewTool(
- ToolGetGraphqlSchemaName,
- mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Get GraphQL Schema for Nhost Cloud Platform",
- ReadOnlyHint: ptr(true),
- DestructiveHint: ptr(false),
- IdempotentHint: ptr(true),
- OpenWorldHint: ptr(true),
- },
- ),
- )
- mcpServer.AddTool(schemaTool, t.handleGetGraphqlSchema)
-}
-
-func (t *Tool) handleGetGraphqlSchema(
- _ context.Context, _ mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
- schema := schemaGraphql
- if t.withMutations {
- schema = schemaGraphqlWithMutations
- }
-
- return mcp.NewToolResultStructured(schema, schema), nil
-}
diff --git a/cli/mcp/tools/cloud/query.go b/cli/mcp/tools/cloud/query.go
index 15b559a9b..31f468f0f 100644
--- a/cli/mcp/tools/cloud/query.go
+++ b/cli/mcp/tools/cloud/query.go
@@ -12,7 +12,7 @@ import (
const (
ToolGraphqlQueryName = "cloud-graphql-query"
//nolint:lll
- ToolGraphqlQueryInstructions = `Execute a GraphQL query against the Nhost Cloud to perform operations on projects and organizations. It also allows configuring projects hosted on Nhost Cloud. Make sure you got the schema before attempting to execute any query. If you get an error while performing a query refresh the schema in case something has changed or you did something wrong. If you get an error indicating mutations are not allowed the user may have disabled them in the server, don't retry and ask the user they need to pass --with-cloud-mutations when starting mcp-nhost to enable them. Projects are apps.`
+ ToolGraphqlQueryInstructions = `Execute a GraphQL query against the Nhost Cloud to perform operations on projects and organizations. It also allows configuring projects hosted on Nhost Cloud. Make sure you got the schema before attempting to execute any query. If you get an error while performing a query refresh the schema in case something has changed or you did something wrong. If you get an error indicating mutations are not allowed the user may have disabled them in the server, don't retry and ask the user they need to pass --with-cloud-mutations when starting nhost's mcp to enable them. Projects are apps.`
)
func ptr[T any](v T) *T {
@@ -25,7 +25,6 @@ type GraphqlQueryRequest struct {
}
func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
- t.registerGetGraphqlSchema(mcpServer)
queryTool := mcp.NewTool(
ToolGraphqlQueryName,
mcp.WithDescription(ToolGraphqlQueryInstructions),
@@ -60,7 +59,7 @@ func (t *Tool) handleGraphqlQuery(
allowedMutations := []string{}
if t.withMutations {
- allowedMutations = nil
+ allowedMutations = []string{"*"}
}
var resp graphql.Response[any]
@@ -70,7 +69,7 @@ func (t *Tool) handleGraphqlQuery(
args.Query,
args.Variables,
&resp,
- nil,
+ []string{"*"},
allowedMutations,
t.interceptors...,
); err != nil {
diff --git a/cli/mcp/tools/local/config_server_get_schema.go b/cli/mcp/tools/local/config_server_get_schema.go
deleted file mode 100644
index 8bd739938..000000000
--- a/cli/mcp/tools/local/config_server_get_schema.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package local
-
-import (
- "context"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "github.com/nhost/nhost/cli/mcp/graphql"
-)
-
-const (
- ToolConfigServerSchemaName = "local-config-server-get-schema"
- //nolint:lll
- ToolConfigServerSchemaInstructions = `Get GraphQL schema for the local config server. This tool is useful when the user is developing a project and wants help changing the project's settings.`
-)
-
-func (t *Tool) registerGetConfigServerSchema(mcpServer *server.MCPServer) {
- configServerSchemaTool := mcp.NewTool(
- ToolConfigServerSchemaName,
- mcp.WithDescription(ToolConfigServerSchemaInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Get GraphQL Schema for Nhost Config Server",
- ReadOnlyHint: ptr(true),
- DestructiveHint: ptr(false),
- IdempotentHint: ptr(true),
- OpenWorldHint: ptr(true),
- },
- ),
- mcp.WithBoolean(
- "includeQueries",
- mcp.Description("include queries in the schema"),
- mcp.Required(),
- ),
- mcp.WithBoolean(
- "includeMutations",
- mcp.Description("include mutations in the schema"),
- mcp.Required(),
- ),
- )
- mcpServer.AddTool(
- configServerSchemaTool,
- mcp.NewStructuredToolHandler(t.handleConfigGetServerSchema),
- )
-}
-
-type ConfigServerGetSchemaRequest struct {
- IncludeQueries bool `json:"includeQueries"`
- IncludeMutations bool `json:"includeMutations"`
-}
-
-func (t *Tool) handleConfigGetServerSchema(
- ctx context.Context, _ mcp.CallToolRequest, args ConfigServerGetSchemaRequest,
-) (*mcp.CallToolResult, error) {
- var introspection graphql.ResponseIntrospection
- if err := graphql.Query(
- ctx,
- t.configServerURL,
- graphql.IntrospectionQuery,
- nil,
- &introspection,
- nil,
- nil,
- t.interceptors...,
- ); err != nil {
- return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
- }
-
- var allowQueries, allowMutations []graphql.Queries
- if !args.IncludeQueries {
- allowQueries = []graphql.Queries{}
- }
-
- if !args.IncludeMutations {
- allowMutations = []graphql.Queries{}
- }
-
- schema := graphql.ParseSchema(
- introspection,
- graphql.Filter{
- AllowQueries: allowQueries,
- AllowMutations: allowMutations,
- },
- )
-
- return mcp.NewToolResultStructured(schema, schema), nil
-}
diff --git a/cli/mcp/tools/local/config_server_query.go b/cli/mcp/tools/local/config_server_query.go
deleted file mode 100644
index d1c93df46..000000000
--- a/cli/mcp/tools/local/config_server_query.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package local
-
-import (
- "context"
- "encoding/json"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "github.com/nhost/nhost/cli/mcp/graphql"
-)
-
-const (
- ToolConfigServerQueryName = "local-config-server-query"
- //nolint:lll
- ToolConfigServerQueryInstructions = `Execute a GraphQL query against the local config server. This tool is useful to query and perform configuration changes on the local development project. Before using this tool, make sure to get the schema using the local-config-server-schema tool. To perform configuration changes this endpoint is all you need but to apply them you need to run 'nhost up' again. Ask the user for input when you need information about settings, for instance if the user asks to enable some oauth2 method and you need the client id or secret.`
-)
-
-type ConfigServerQueryRequest struct {
- Query string `json:"query"`
- Variables map[string]any `json:"variables,omitempty"`
-}
-
-func (t *Tool) registerConfigServerQuery(mcpServer *server.MCPServer) {
- configServerQueryTool := mcp.NewTool(
- ToolConfigServerQueryName,
- mcp.WithDescription(ToolConfigServerQueryInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Perform GraphQL Query on Nhost Config Server",
- ReadOnlyHint: ptr(false),
- DestructiveHint: ptr(true),
- IdempotentHint: ptr(false),
- OpenWorldHint: ptr(true),
- },
- ),
- mcp.WithString(
- "query",
- mcp.Description("graphql query to perform"),
- mcp.Required(),
- ),
- mcp.WithString(
- "variables",
- mcp.Description("variables to use in the query"),
- ),
- )
- mcpServer.AddTool(
- configServerQueryTool,
- mcp.NewStructuredToolHandler(t.handleConfigServerQuery),
- )
-}
-
-func (t *Tool) handleConfigServerQuery(
- ctx context.Context, _ mcp.CallToolRequest, args ConfigServerQueryRequest,
-) (*mcp.CallToolResult, error) {
- if args.Query == "" {
- return mcp.NewToolResultError("query is required"), nil
- }
-
- var resp graphql.Response[any]
- if err := graphql.Query(
- ctx,
- t.configServerURL,
- args.Query,
- args.Variables,
- &resp,
- nil,
- nil,
- t.interceptors...,
- ); err != nil {
- return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
- }
-
- b, err := json.Marshal(resp)
- if err != nil {
- return mcp.NewToolResultErrorFromErr("error marshalling response", err), nil
- }
-
- return mcp.NewToolResultStructured(resp, string(b)), nil
-}
diff --git a/cli/mcp/tools/local/get_graphql_management_schema.go b/cli/mcp/tools/local/get_graphql_management_schema.go
deleted file mode 100644
index fbf95023d..000000000
--- a/cli/mcp/tools/local/get_graphql_management_schema.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package local
-
-import (
- "context"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "github.com/nhost/nhost/cli/mcp/nhost/graphql"
-)
-
-const (
- ToolGetGraphqlManagementSchemaName = "local-get-management-graphql-schema"
- ToolGetGraphqlManagementSchemaInstructions = `
- Get GraphQL's management schema for an Nhost development project running locally via the Nhost
- CLI. This tool is useful to properly understand how manage hasura metadata, migrations,
- permissions, remote schemas, etc.
-
- Use it before attempting to use ` + ToolManageGraphqlName
-)
-
-func (t *Tool) registerGetGraphqlManagementSchema(mcpServer *server.MCPServer) {
- schemaTool := mcp.NewTool(
- ToolGetGraphqlManagementSchemaName,
- mcp.WithDescription(ToolGetGraphqlManagementSchemaInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Get GraphQL's Management Schema for Nhost Development Project",
- ReadOnlyHint: ptr(true),
- DestructiveHint: ptr(false),
- IdempotentHint: ptr(true),
- OpenWorldHint: ptr(true),
- },
- ),
- )
- mcpServer.AddTool(schemaTool, t.handleGetGraphqlManagementSchema)
-}
-
-func (t *Tool) handleGetGraphqlManagementSchema(
- _ context.Context, _ mcp.CallToolRequest,
-) (*mcp.CallToolResult, error) {
- return mcp.NewToolResultText(graphql.Schema), nil
-}
diff --git a/cli/mcp/tools/local/get_schema.go b/cli/mcp/tools/local/get_schema.go
deleted file mode 100644
index 8ef6fb8f8..000000000
--- a/cli/mcp/tools/local/get_schema.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package local
-
-import (
- "context"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "github.com/nhost/nhost/cli/mcp/graphql"
- "github.com/nhost/nhost/cli/mcp/nhost/auth"
-)
-
-const (
- ToolGetGraphqlSchemaName = "local-get-graphql-schema"
- //nolint:lll
- ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost development project running locally via the Nhost CLI. This tool is useful when the user is developing a project and wants help generating code to interact with their project's Graphql schema.`
-)
-
-type GetGraphqlSchemaRequest struct {
- Role string `json:"role"`
-}
-
-func (t *Tool) registerGetGraphqlSchema(mcpServer *server.MCPServer) {
- schemaTool := mcp.NewTool(
- ToolGetGraphqlSchemaName,
- mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Get GraphQL Schema for Nhost Development Project",
- ReadOnlyHint: ptr(true),
- DestructiveHint: ptr(false),
- IdempotentHint: ptr(true),
- OpenWorldHint: ptr(true),
- },
- ),
- mcp.WithString(
- "role",
- mcp.Description(
- "role to use when executing queries. Default to user but make sure the user is aware",
- ),
- mcp.Required(),
- ),
- )
- mcpServer.AddTool(schemaTool, mcp.NewStructuredToolHandler(t.handleGetGraphqlSchema))
-}
-
-func (t *Tool) handleGetGraphqlSchema(
- ctx context.Context, _ mcp.CallToolRequest, args GetGraphqlSchemaRequest,
-) (*mcp.CallToolResult, error) {
- if args.Role == "" {
- return mcp.NewToolResultError("role is required"), nil
- }
-
- interceptors := append( //nolint:gocritic
- t.interceptors,
- auth.WithRole(args.Role),
- )
-
- var introspection graphql.ResponseIntrospection
- if err := graphql.Query(
- ctx,
- t.graphqlURL,
- graphql.IntrospectionQuery,
- nil,
- &introspection,
- nil,
- nil,
- interceptors...,
- ); err != nil {
- return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
- }
-
- schema := graphql.ParseSchema(
- introspection,
- graphql.Filter{
- AllowQueries: nil,
- AllowMutations: nil,
- },
- )
-
- return mcp.NewToolResultText(schema), nil
-}
diff --git a/cli/mcp/tools/local/local.go b/cli/mcp/tools/local/local.go
deleted file mode 100644
index 834b0f4f1..000000000
--- a/cli/mcp/tools/local/local.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package local
-
-import (
- "context"
- "net/http"
-
- "github.com/mark3labs/mcp-go/server"
-)
-
-type Tool struct {
- graphqlURL string
- configServerURL string
- interceptors []func(ctx context.Context, req *http.Request) error
-}
-
-func NewTool(
- graphqlURL string,
- configServerURL string,
- interceptors ...func(ctx context.Context, req *http.Request) error,
-) *Tool {
- return &Tool{
- graphqlURL: graphqlURL,
- configServerURL: configServerURL,
- interceptors: interceptors,
- }
-}
-
-func (t *Tool) Register(mcpServer *server.MCPServer) error {
- t.registerGetGraphqlSchema(mcpServer)
- t.registerGraphqlQuery(mcpServer)
- t.registerGetConfigServerSchema(mcpServer)
- t.registerConfigServerQuery(mcpServer)
- t.registerGetGraphqlManagementSchema(mcpServer)
- t.registerManageGraphql(mcpServer)
-
- return nil
-}
diff --git a/cli/mcp/tools/local/query.go b/cli/mcp/tools/local/query.go
deleted file mode 100644
index e2faf3350..000000000
--- a/cli/mcp/tools/local/query.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package local
-
-import (
- "context"
- "encoding/json"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "github.com/nhost/nhost/cli/mcp/graphql"
- "github.com/nhost/nhost/cli/mcp/nhost/auth"
-)
-
-const (
- ToolGraphqlQueryName = "local-graphql-query"
- //nolint:lll
- ToolGraphqlQueryInstructions = `Execute a GraphQL query against an Nhost development project running locally via the Nhost CLI. This tool is useful to test queries and mutations during development. If you run into issues executing queries, retrieve the schema using the local-get-graphql-schema tool in case the schema has changed.`
-)
-
-func ptr[T any](v T) *T {
- return &v
-}
-
-type GraphqlQueryRequest struct {
- Query string `json:"query"`
- Variables map[string]any `json:"variables,omitempty"`
- Role string `json:"role"`
-}
-
-func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
- queryTool := mcp.NewTool(
- ToolGraphqlQueryName,
- mcp.WithDescription(ToolGraphqlQueryInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Perform GraphQL Query on Nhost Development Project",
- ReadOnlyHint: ptr(false),
- DestructiveHint: ptr(true),
- IdempotentHint: ptr(false),
- OpenWorldHint: ptr(true),
- },
- ),
- mcp.WithString(
- "query",
- mcp.Description("graphql query to perform"),
- mcp.Required(),
- ),
- mcp.WithObject(
- "variables",
- mcp.Description("variables to use in the query"),
- mcp.AdditionalProperties(true),
- ),
- mcp.WithString(
- "role",
- mcp.Description(
- "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
- ),
- mcp.Required(),
- ),
- )
- mcpServer.AddTool(queryTool, mcp.NewStructuredToolHandler(t.handleGraphqlQuery))
-}
-
-func (t *Tool) handleGraphqlQuery(
- ctx context.Context, _ mcp.CallToolRequest, args GraphqlQueryRequest,
-) (*mcp.CallToolResult, error) {
- if args.Query == "" {
- return mcp.NewToolResultError("query is required"), nil
- }
-
- if args.Role == "" {
- return mcp.NewToolResultError("role is required"), nil
- }
-
- interceptors := append( //nolint:gocritic
- t.interceptors,
- auth.WithRole(args.Role),
- )
-
- var resp graphql.Response[any]
- if err := graphql.Query(
- ctx,
- t.graphqlURL,
- args.Query,
- args.Variables,
- &resp,
- nil,
- nil,
- interceptors...,
- ); err != nil {
- return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
- }
-
- b, err := json.Marshal(resp)
- if err != nil {
- return mcp.NewToolResultErrorFromErr("error marshalling response", err), nil
- }
-
- return mcp.NewToolResultStructured(resp, string(b)), nil
-}
diff --git a/cli/mcp/tools/project/get_schema.go b/cli/mcp/tools/project/get_schema.go
deleted file mode 100644
index 18b112c25..000000000
--- a/cli/mcp/tools/project/get_schema.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package project
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
-
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "github.com/nhost/nhost/cli/mcp/graphql"
- "github.com/nhost/nhost/cli/mcp/nhost/auth"
-)
-
-const (
- ToolGetGraphqlSchemaName = "project-get-graphql-schema"
- ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost project running in the Nhost Cloud.`
-)
-
-var (
- ErrNotFound = errors.New("not found")
- ErrInvalidRequestBody = errors.New("invalid request body")
-)
-
-type GetGraphqlSchemaRequest struct {
- Role string `json:"role"`
- ProjectSubdomain string `json:"projectSubdomain"`
-}
-
-func (t *Tool) registerGetGraphqlSchemaTool(mcpServer *server.MCPServer, projects string) {
- schemaTool := mcp.NewTool(
- ToolGetGraphqlSchemaName,
- mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
- mcp.WithToolAnnotation(
- mcp.ToolAnnotation{
- Title: "Get GraphQL Schema for Nhost Project running on Nhost Cloud",
- ReadOnlyHint: ptr(true),
- DestructiveHint: ptr(false),
- IdempotentHint: ptr(true),
- OpenWorldHint: ptr(true),
- },
- ),
- mcp.WithString(
- "role",
- mcp.Description(
- "role to use when executing queries. Default to user but make sure the user is aware",
- ),
- mcp.Required(),
- ),
- mcp.WithString(
- "projectSubdomain",
- mcp.Description(
- fmt.Sprintf(
- "Project to get the GraphQL schema for. Must be one of %s, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names", //nolint:lll
- projects,
- ),
- ),
- mcp.Required(),
- ),
- )
- mcpServer.AddTool(schemaTool, mcp.NewStructuredToolHandler(t.handleGetGraphqlSchema))
-}
-
-func (t *Tool) handleGetGraphqlSchema(
- ctx context.Context, _ mcp.CallToolRequest, args GetGraphqlSchemaRequest,
-) (*mcp.CallToolResult, error) {
- if args.Role == "" {
- return mcp.NewToolResultError("role is required"), nil
- }
-
- if args.ProjectSubdomain == "" {
- return mcp.NewToolResultError("projectSubdomain is required"), nil
- }
-
- project, ok := t.projects[args.ProjectSubdomain]
- if !ok {
- return mcp.NewToolResultError("project not configured to be accessed by an LLM"), nil
- }
-
- interceptors := []func(ctx context.Context, req *http.Request) error{
- project.authInterceptor,
- auth.WithRole(args.Role),
- }
-
- var introspection graphql.ResponseIntrospection
- if err := graphql.Query(
- ctx,
- project.graphqlURL,
- graphql.IntrospectionQuery,
- nil,
- &introspection,
- nil,
- nil,
- interceptors...,
- ); err != nil {
- return mcp.NewToolResultErrorFromErr("failed to query GraphQL schema", err), nil
- }
-
- schema := graphql.ParseSchema(
- introspection,
- graphql.Filter{
- AllowQueries: nil,
- AllowMutations: nil,
- },
- )
-
- return mcp.NewToolResultStructured(schema, schema), nil
-}
diff --git a/cli/mcp/tools/local/manage_graphql.go b/cli/mcp/tools/project/manage_graphql.go
similarity index 72%
rename from cli/mcp/tools/local/manage_graphql.go
rename to cli/mcp/tools/project/manage_graphql.go
index 9779035a2..efc9fba60 100644
--- a/cli/mcp/tools/local/manage_graphql.go
+++ b/cli/mcp/tools/project/manage_graphql.go
@@ -1,4 +1,4 @@
-package local
+package project
import (
"context"
@@ -10,22 +10,21 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
+ "github.com/nhost/nhost/cli/mcp/nhost/auth"
)
const (
- ToolManageGraphqlName = "local-manage-graphql"
+ ToolManageGraphqlName = "manage-graphql"
ToolManageGraphqlInstructions = `
- Query GraphQL's management endpoints on an Nhost development project running locally via
- the Nhost CLI. This tool is useful to manage hasura metadata, migrations, permissions,
- remote schemas, database migrations, etc. It also allows to interact with the underlying
- database directly.
+ Query GraphQL's management endpoints on an Nhost project running. This tool is useful to
+ manage hasura metadata, migrations, permissions, remote schemas, database migrations,
+ etc. It also allows to interact with the underlying database directly.
- * Do not forget to use the base url in the endpoint.
* Before using this tool always describe in natural languate what you are about to do.
## Metadata changes
- * When changing metadata always use the /apis/migrate endpoint
+ * When changing metadata ALWAYS use the /apis/migrate endpoint
* Always perform a bulk request to avoid
having to perform multiple requests
* The admin user always has full permissions to everything by default, no need to configure
@@ -55,8 +54,9 @@ const (
)
type ManageGraphqlRequest struct {
- Endpoint string `json:"endpoint"`
- Body string `json:"body"`
+ Body string `json:"body"`
+ Subdomain string `json:"subdomain"`
+ Path string `json:"path"`
}
func (t *Tool) registerManageGraphql(mcpServer *server.MCPServer) {
@@ -73,10 +73,14 @@ func (t *Tool) registerManageGraphql(mcpServer *server.MCPServer) {
},
),
mcp.WithString(
- "endpoint",
- mcp.Description(
- "The GraphQL management endpoint to query. Use https://local.hasura.local.nhost.run as base URL",
- ),
+ "subdomain",
+ mcp.Description("Project to perform the GraphQL management operation against"),
+ mcp.Enum(t.cfg.Projects.Subdomains()...),
+ mcp.Required(),
+ ),
+ mcp.WithString(
+ "path",
+ mcp.Description("The path for the HTTP request"),
mcp.Required(),
),
mcp.WithString(
@@ -137,20 +141,37 @@ func genericQuery(
func (t *Tool) handleManageGraphql(
ctx context.Context, _ mcp.CallToolRequest, args ManageGraphqlRequest,
) (*mcp.CallToolResult, error) {
- if args.Endpoint == "" {
- return mcp.NewToolResultError("endpoint is required"), nil
- }
-
if args.Body == "" {
return mcp.NewToolResultError("body is required"), nil
}
+ if args.Subdomain == "" {
+ return mcp.NewToolResultError("projectSubdomain is required"), nil
+ }
+
+ project, err := t.cfg.Projects.Get(args.Subdomain)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get project configuration", err), nil
+ }
+
+ if !project.ManageMetadata {
+ return mcp.NewToolResultError("project does not allow metadata management"), nil
+ }
+
+ if project.AdminSecret == nil {
+ return mcp.NewToolResultError("project does not have an admin secret configured"), nil
+ }
+
headers := http.Header{}
headers.Add("Content-Type", "application/json")
headers.Add("Accept", "application/json")
+ interceptors := []func(ctx context.Context, req *http.Request) error{
+ auth.WithAdminSecret(*project.AdminSecret),
+ }
+
response, err := genericQuery(
- ctx, args.Endpoint, args.Body, http.MethodPost, headers, t.interceptors,
+ ctx, project.GetHasuraURL()+args.Path, args.Body, http.MethodPost, headers, interceptors,
)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to execute query", err), nil
diff --git a/cli/mcp/tools/project/project.go b/cli/mcp/tools/project/project.go
index 26db83106..4afc9005f 100644
--- a/cli/mcp/tools/project/project.go
+++ b/cli/mcp/tools/project/project.go
@@ -1,90 +1,25 @@
package project
import (
- "context"
- "fmt"
- "net/http"
- "slices"
- "strings"
-
"github.com/mark3labs/mcp-go/server"
"github.com/nhost/nhost/cli/mcp/config"
- "github.com/nhost/nhost/cli/mcp/nhost/auth"
)
-type Project struct {
- subdomain string
- graphqlURL string
- authInterceptor func(ctx context.Context, req *http.Request) error
- allowQueries []string
- allowMutations []string
-}
-
type Tool struct {
- projects map[string]Project
-}
-
-func allowedQueries(allowQueries []string) []string {
- if len(allowQueries) == 1 && allowQueries[0] == "*" {
- return nil
- }
-
- return allowQueries
+ cfg *config.Config
}
func NewTool(
- projList []config.Project,
-) (*Tool, error) {
- projects := make(map[string]Project)
-
- for _, proj := range projList {
- authURL := fmt.Sprintf("https://%s.auth.%s.nhost.run/v1", proj.Subdomain, proj.Region)
- graphqlURL := fmt.Sprintf("https://%s.graphql.%s.nhost.run/v1", proj.Subdomain, proj.Region)
-
- var interceptor func(ctx context.Context, req *http.Request) error
-
- switch {
- case proj.AdminSecret != nil && *proj.AdminSecret != "":
- interceptor = auth.WithAdminSecret(*proj.AdminSecret)
- case proj.PAT != nil && *proj.PAT != "":
- var err error
-
- interceptor, err = auth.WithPAT(authURL, *proj.PAT)
- if err != nil {
- return nil,
- fmt.Errorf("failed to create PAT interceptor for %s: %w", proj.Subdomain, err)
- }
- default:
- return nil, fmt.Errorf( //nolint:err113
- "project %s does not have a valid auth mechanism", proj.Subdomain)
- }
-
- projects[proj.Subdomain] = Project{
- subdomain: proj.Subdomain,
- graphqlURL: graphqlURL,
- authInterceptor: interceptor,
- allowQueries: allowedQueries(proj.AllowQueries),
- allowMutations: allowedQueries(proj.AllowMutations),
- }
- }
-
+ cfg *config.Config,
+) *Tool {
return &Tool{
- projects: projects,
- }, nil
+ cfg: cfg,
+ }
}
func (t *Tool) Register(mcpServer *server.MCPServer) error {
- projectNames := make([]string, 0, len(t.projects))
- for _, proj := range t.projects {
- projectNames = append(projectNames, proj.subdomain)
- }
-
- slices.Sort(projectNames)
-
- projectNamesStr := strings.Join(projectNames, ", ")
-
- t.registerGetGraphqlSchemaTool(mcpServer, projectNamesStr)
- t.registerGraphqlQuery(mcpServer, projectNamesStr)
+ t.registerGraphqlQuery(mcpServer)
+ t.registerManageGraphql(mcpServer)
return nil
}
diff --git a/cli/mcp/tools/project/query.go b/cli/mcp/tools/project/query.go
index bbb8ce436..dea26efa0 100644
--- a/cli/mcp/tools/project/query.go
+++ b/cli/mcp/tools/project/query.go
@@ -3,7 +3,6 @@ package project
import (
"context"
"encoding/json"
- "fmt"
"net/http"
"github.com/mark3labs/mcp-go/mcp"
@@ -13,9 +12,9 @@ import (
)
const (
- ToolGraphqlQueryName = "project-graphql-query"
+ ToolGraphqlQueryName = "graphql-query"
//nolint:lll
- ToolGraphqlQueryInstructions = `Execute a GraphQL query against a Nhost project running in the Nhost Cloud. This tool is useful to query and mutate live data running on an online projec. If you run into issues executing queries, retrieve the schema using the project-get-graphql-schema tool in case the schema has changed. If you get an error indicating the query or mutation is not allowed the user may have disabled them in the server, don't retry and tell the user they need to enable them when starting mcp-nhost`
+ ToolGraphqlQueryInstructions = `Execute a GraphQL query against a Nhost project. This tool is useful to query and mutate data. If you run into issues executing queries, retrieve the schema again in case the schema has changed. If you get an error indicating the query or mutation is not allowed the user may have disabled them in the server, don't retry and tell the user they need to enable them when starting nhost's mcp`
)
func ptr[T any](v T) *T {
@@ -23,18 +22,18 @@ func ptr[T any](v T) *T {
}
type GraphqlQueryRequest struct {
- Query string `json:"query"`
- Variables map[string]any `json:"variables,omitempty"`
- ProjectSubdomain string `json:"projectSubdomain"`
- Role string `json:"role"`
- UserID string `json:"userId,omitempty"`
+ Query string `json:"query"`
+ Variables map[string]any `json:"variables,omitempty"`
+ Subdomain string `json:"subdomain"`
+ Role string `json:"role"`
+ UserID string `json:"userId,omitempty"`
}
-func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer, projects string) {
+func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer) {
allowedMutations := false
- for _, proj := range t.projects {
- if proj.allowMutations == nil || len(proj.allowMutations) > 0 {
+ for _, proj := range t.cfg.Projects {
+ if proj.AllowMutations == nil || len(proj.AllowMutations) > 0 {
allowedMutations = true
break
}
@@ -62,19 +61,15 @@ func (t *Tool) registerGraphqlQuery(mcpServer *server.MCPServer, projects string
mcp.Description("variables to use in the query"),
),
mcp.WithString(
- "projectSubdomain",
- mcp.Description(
- fmt.Sprintf(
- "Project to get the GraphQL schema for. Must be one of %s, otherwise you don't have access to it. You can use cloud-* tools to resolve subdomains and map them to names", //nolint:lll
- projects,
- ),
- ),
+ "subdomain",
+ mcp.Description("Project to perform the GraphQL query against"),
+ mcp.Enum(t.cfg.Projects.Subdomains()...),
mcp.Required(),
),
mcp.WithString(
"role",
mcp.Description(
- "role to use when executing queries. Default to user but make sure the user is aware. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
+ "role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
),
mcp.Required(),
),
@@ -95,7 +90,7 @@ func (t *Tool) handleGraphqlQuery(
return mcp.NewToolResultError("query is required"), nil
}
- if args.ProjectSubdomain == "" {
+ if args.Subdomain == "" {
return mcp.NewToolResultError("projectSubdomain is required"), nil
}
@@ -103,15 +98,18 @@ func (t *Tool) handleGraphqlQuery(
return mcp.NewToolResultError("role is required"), nil
}
- project, ok := t.projects[args.ProjectSubdomain]
- if !ok {
- return mcp.NewToolResultError(
- "this project is not configured to be accessed by an LLM",
- ), nil
+ project, err := t.cfg.Projects.Get(args.Subdomain)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get project configuration", err), nil
+ }
+
+ authInterceptor, err := project.GetAuthInterceptor()
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get auth interceptor", err), nil
}
interceptors := []func(ctx context.Context, req *http.Request) error{
- project.authInterceptor,
+ authInterceptor,
auth.WithRole(args.Role),
}
@@ -122,12 +120,12 @@ func (t *Tool) handleGraphqlQuery(
var resp graphql.Response[any]
if err := graphql.Query(
ctx,
- project.graphqlURL,
+ project.GetGraphqlURL(),
args.Query,
args.Variables,
&resp,
- project.allowQueries,
- project.allowMutations,
+ project.AllowQueries,
+ project.AllowMutations,
interceptors...,
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to query graphql endpoint", err), nil
diff --git a/cli/mcp/tools/schemas/project_schema.go b/cli/mcp/tools/schemas/project_schema.go
new file mode 100644
index 000000000..391d7e6e2
--- /dev/null
+++ b/cli/mcp/tools/schemas/project_schema.go
@@ -0,0 +1,94 @@
+package schemas
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/nhost/nhost/cli/mcp/graphql"
+ "github.com/nhost/nhost/cli/mcp/nhost/auth"
+)
+
+const (
+ ToolGetGraphqlSchemaName = "project-get-graphql-schema"
+ ToolGetGraphqlSchemaInstructions = `Get GraphQL schema for an Nhost project running in the Nhost Cloud.`
+)
+
+var (
+ ErrNotFound = errors.New("not found")
+ ErrInvalidRequestBody = errors.New("invalid request body")
+)
+
+type GetGraphqlSchemaRequest struct {
+ Role string `json:"role"`
+ ProjectSubdomain string `json:"projectSubdomain"`
+}
+
+func toQueries(q []string) []graphql.Queries {
+ if q == nil {
+ return nil
+ }
+
+ queries := make([]graphql.Queries, len(q))
+ for i, v := range q {
+ queries[i] = graphql.Queries{
+ Name: v,
+ DisableNesting: false,
+ }
+ }
+
+ return queries
+}
+
+func (t *Tool) handleProjectGraphqlSchema(
+ ctx context.Context,
+ role string,
+ subdomain string,
+ summary bool,
+ queries, mutations []string,
+) (string, error) {
+ project, err := t.cfg.Projects.Get(subdomain)
+ if err != nil {
+ return "", fmt.Errorf("failed to get project by subdomain: %w", err)
+ }
+
+ authInterceptor, err := project.GetAuthInterceptor()
+ if err != nil {
+ return "", fmt.Errorf("failed to get auth interceptor: %w", err)
+ }
+
+ interceptors := []func(ctx context.Context, req *http.Request) error{
+ authInterceptor,
+ auth.WithRole(role),
+ }
+
+ var introspection graphql.ResponseIntrospection
+ if err := graphql.Query(
+ ctx,
+ project.GetGraphqlURL(),
+ graphql.IntrospectionQuery,
+ nil,
+ &introspection,
+ []string{"*"},
+ nil,
+ interceptors...,
+ ); err != nil {
+ return "", fmt.Errorf("failed to query GraphQL schema: %w", err)
+ }
+
+ var schema string
+ if summary {
+ schema = graphql.SummarizeSchema(introspection)
+ } else {
+ schema = graphql.ParseSchema(
+ introspection,
+ graphql.Filter{
+ AllowQueries: toQueries(queries),
+ AllowMutations: toQueries(mutations),
+ },
+ )
+ }
+
+ return schema, nil
+}
diff --git a/cli/mcp/tools/schemas/schemas.go b/cli/mcp/tools/schemas/schemas.go
new file mode 100644
index 000000000..9338157b6
--- /dev/null
+++ b/cli/mcp/tools/schemas/schemas.go
@@ -0,0 +1,103 @@
+package schemas
+
+import (
+ "context"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/nhost/nhost/cli/mcp/config"
+)
+
+const (
+ ToolGetSchemaName = "get-schema"
+ ToolGetSchemaInstructions = `
+Get GraphQL/API schemas to interact with various services. Use the "service" parameter to
+specify which schema you want. Supported services are:
+
+- project: Get GraphQL schema for an Nhost project. The "subdomain"
+ parameter is required to specify which project to get the schema for. The "role"
+ parameter can be passed to specify the role to use when fetching the schema (defaults
+ to admin).
+`
+)
+
+func ptr[T any](v T) *T {
+ return &v
+}
+
+type Tool struct {
+ cfg *config.Config
+}
+
+func NewTool(cfg *config.Config) *Tool {
+ return &Tool{cfg: cfg}
+}
+
+func (t *Tool) Register(mcpServer *server.MCPServer) {
+ queryTool := mcp.NewTool(
+ ToolGetSchemaName,
+ mcp.WithDescription(ToolGetGraphqlSchemaInstructions),
+ mcp.WithToolAnnotation(
+ mcp.ToolAnnotation{
+ Title: "Get GraphQL/API schema for various services",
+ ReadOnlyHint: ptr(true),
+ DestructiveHint: ptr(false),
+ IdempotentHint: ptr(true),
+ OpenWorldHint: ptr(true),
+ },
+ ),
+ mcp.WithString(
+ "role",
+ mcp.Description(
+ "role to use when executing queries. Keep in mind the schema depends on the role so if you retrieved the schema for a different role previously retrieve it for this role beforehand as it might differ", //nolint:lll
+ ),
+ mcp.Required(),
+ ),
+ mcp.WithString(
+ "subdomain",
+ mcp.Description(
+ "Project to get the GraphQL schema for. Required when service is `project`",
+ ),
+ mcp.Enum(t.cfg.Projects.Subdomains()...),
+ mcp.Required(),
+ ),
+ mcp.WithBoolean(
+ "summary",
+ mcp.Description("only return a summary of the schema"),
+ mcp.DefaultBool(true),
+ ),
+ mcp.WithArray(
+ "queries",
+ mcp.WithStringItems(),
+ mcp.Description("list of queries to fetch"),
+ ),
+ mcp.WithArray(
+ "mutations",
+ mcp.WithStringItems(),
+ mcp.Description("list of mutations to fetch"),
+ ),
+ )
+
+ mcpServer.AddTool(queryTool, mcp.NewStructuredToolHandler(t.handle))
+}
+
+type HandleRequest struct {
+ Role string `json:"role,omitempty"`
+ Subdomain string `json:"subdomain,omitempty"`
+ Summary bool `json:"summary,omitempty"`
+ Queries []string `json:"queries,omitempty"`
+ Mutations []string `json:"mutations,omitempty"`
+}
+
+func (t *Tool) handle(
+ ctx context.Context, _ mcp.CallToolRequest, args HandleRequest,
+) (*mcp.CallToolResult, error) {
+ schema, err := t.handleProjectGraphqlSchema(
+ ctx, args.Role, args.Subdomain, args.Summary, args.Queries, args.Mutations,
+ )
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ return mcp.NewToolResultText(schema), nil
+}
diff --git a/cli/nhostclient/graphql/models_gen.go b/cli/nhostclient/graphql/models_gen.go
index 41c062249..f8ecc0c26 100644
--- a/cli/nhostclient/graphql/models_gen.go
+++ b/cli/nhostclient/graphql/models_gen.go
@@ -70,18 +70,28 @@ type ConfigAIUpdateInput struct {
WebhookSecret *string `json:"webhookSecret,omitempty"`
}
+// Configuration for auth service
+// You can find more information about the configuration here:
+// https://github.com/nhost/hasura-auth/blob/main/docs/environment-variables.md
type ConfigAuth struct {
ElevatedPrivileges *ConfigAuthElevatedPrivileges `json:"elevatedPrivileges,omitempty"`
Method *ConfigAuthMethod `json:"method,omitempty"`
Misc *ConfigAuthMisc `json:"misc,omitempty"`
RateLimit *ConfigAuthRateLimit `json:"rateLimit,omitempty"`
Redirections *ConfigAuthRedirections `json:"redirections,omitempty"`
- Resources *ConfigResources `json:"resources,omitempty"`
- Session *ConfigAuthSession `json:"session,omitempty"`
- SignUp *ConfigAuthSignUp `json:"signUp,omitempty"`
- Totp *ConfigAuthTotp `json:"totp,omitempty"`
- User *ConfigAuthUser `json:"user,omitempty"`
- Version *string `json:"version,omitempty"`
+ // Resources for the service
+ Resources *ConfigResources `json:"resources,omitempty"`
+ Session *ConfigAuthSession `json:"session,omitempty"`
+ SignUp *ConfigAuthSignUp `json:"signUp,omitempty"`
+ Totp *ConfigAuthTotp `json:"totp,omitempty"`
+ User *ConfigAuthUser `json:"user,omitempty"`
+ // Version of auth, you can see available versions in the URL below:
+ // https://hub.docker.com/r/nhost/hasura-auth/tags
+ //
+ // Releases:
+ //
+ // https://github.com/nhost/hasura-auth/releases
+ Version *string `json:"version,omitempty"`
}
type ConfigAuthElevatedPrivileges struct {
@@ -111,9 +121,11 @@ type ConfigAuthMethodAnonymousUpdateInput struct {
}
type ConfigAuthMethodEmailPassword struct {
- EmailVerificationRequired *bool `json:"emailVerificationRequired,omitempty"`
- HibpEnabled *bool `json:"hibpEnabled,omitempty"`
- PasswordMinLength *uint32 `json:"passwordMinLength,omitempty"`
+ EmailVerificationRequired *bool `json:"emailVerificationRequired,omitempty"`
+ // Disabling email+password sign in is not implmented yet
+ // enabled: bool | *true
+ HibpEnabled *bool `json:"hibpEnabled,omitempty"`
+ PasswordMinLength *uint32 `json:"passwordMinLength,omitempty"`
}
type ConfigAuthMethodEmailPasswordUpdateInput struct {
@@ -335,8 +347,10 @@ type ConfigAuthRateLimitUpdateInput struct {
}
type ConfigAuthRedirections struct {
+ // AUTH_ACCESS_CONTROL_ALLOWED_REDIRECT_URLS
AllowedUrls []string `json:"allowedUrls,omitempty"`
- ClientURL *string `json:"clientUrl,omitempty"`
+ // AUTH_CLIENT_URL
+ ClientURL *string `json:"clientUrl,omitempty"`
}
type ConfigAuthRedirectionsUpdateInput struct {
@@ -350,8 +364,10 @@ type ConfigAuthSession struct {
}
type ConfigAuthSessionAccessToken struct {
+ // AUTH_JWT_CUSTOM_CLAIMS
CustomClaims []*ConfigAuthsessionaccessTokenCustomClaims `json:"customClaims,omitempty"`
- ExpiresIn *uint32 `json:"expiresIn,omitempty"`
+ // AUTH_ACCESS_TOKEN_EXPIRES_IN
+ ExpiresIn *uint32 `json:"expiresIn,omitempty"`
}
type ConfigAuthSessionAccessTokenUpdateInput struct {
@@ -360,6 +376,7 @@ type ConfigAuthSessionAccessTokenUpdateInput struct {
}
type ConfigAuthSessionRefreshToken struct {
+ // AUTH_REFRESH_TOKEN_EXPIRES_IN
ExpiresIn *uint32 `json:"expiresIn,omitempty"`
}
@@ -373,9 +390,11 @@ type ConfigAuthSessionUpdateInput struct {
}
type ConfigAuthSignUp struct {
- DisableNewUsers *bool `json:"disableNewUsers,omitempty"`
- Enabled *bool `json:"enabled,omitempty"`
- Turnstile *ConfigAuthSignUpTurnstile `json:"turnstile,omitempty"`
+ // AUTH_DISABLE_NEW_USERS
+ DisableNewUsers *bool `json:"disableNewUsers,omitempty"`
+ // Inverse of AUTH_DISABLE_SIGNUP
+ Enabled *bool `json:"enabled,omitempty"`
+ Turnstile *ConfigAuthSignUpTurnstile `json:"turnstile,omitempty"`
}
type ConfigAuthSignUpTurnstile struct {
@@ -425,12 +444,16 @@ type ConfigAuthUser struct {
}
type ConfigAuthUserEmail struct {
+ // AUTH_ACCESS_CONTROL_ALLOWED_EMAILS
Allowed []string `json:"allowed,omitempty"`
+ // AUTH_ACCESS_CONTROL_BLOCKED_EMAILS
Blocked []string `json:"blocked,omitempty"`
}
type ConfigAuthUserEmailDomains struct {
+ // AUTH_ACCESS_CONTROL_ALLOWED_EMAIL_DOMAINS
Allowed []string `json:"allowed,omitempty"`
+ // AUTH_ACCESS_CONTROL_BLOCKED_EMAIL_DOMAINS
Blocked []string `json:"blocked,omitempty"`
}
@@ -446,6 +469,7 @@ type ConfigAuthUserEmailUpdateInput struct {
type ConfigAuthUserGravatar struct {
Default *string `json:"default,omitempty"`
+ // AUTH_GRAVATAR_ENABLED
Enabled *bool `json:"enabled,omitempty"`
Rating *string `json:"rating,omitempty"`
}
@@ -457,8 +481,10 @@ type ConfigAuthUserGravatarUpdateInput struct {
}
type ConfigAuthUserLocale struct {
+ // AUTH_LOCALE_ALLOWED_LOCALES
Allowed []string `json:"allowed,omitempty"`
- Default *string `json:"default,omitempty"`
+ // AUTH_LOCALE_DEFAULT
+ Default *string `json:"default,omitempty"`
}
type ConfigAuthUserLocaleUpdateInput struct {
@@ -467,8 +493,10 @@ type ConfigAuthUserLocaleUpdateInput struct {
}
type ConfigAuthUserRoles struct {
+ // AUTH_USER_DEFAULT_ALLOWED_ROLES
Allowed []string `json:"allowed,omitempty"`
- Default *string `json:"default,omitempty"`
+ // AUTH_USER_DEFAULT_ROLE
+ Default *string `json:"default,omitempty"`
}
type ConfigAuthUserRolesUpdateInput struct {
@@ -484,6 +512,7 @@ type ConfigAuthUserUpdateInput struct {
Roles *ConfigAuthUserRolesUpdateInput `json:"roles,omitempty"`
}
+// AUTH_JWT_CUSTOM_CLAIMS
type ConfigAuthsessionaccessTokenCustomClaims struct {
Default *string `json:"default,omitempty"`
Key string `json:"key"`
@@ -522,8 +551,11 @@ type ConfigClaimMapUpdateInput struct {
Value *string `json:"value,omitempty"`
}
+// Resource configuration for a service
type ConfigComputeResources struct {
- CPU uint32 `json:"cpu"`
+ // milicpus, 1000 milicpus = 1 cpu
+ CPU uint32 `json:"cpu"`
+ // MiB: 128MiB to 30GiB
Memory uint32 `json:"memory"`
}
@@ -537,17 +569,28 @@ type ConfigComputeResourcesUpdateInput struct {
Memory *uint32 `json:"memory,omitempty"`
}
+// main entrypoint to the configuration
type ConfigConfig struct {
- Ai *ConfigAi `json:"ai,omitempty"`
- Auth *ConfigAuth `json:"auth,omitempty"`
- Functions *ConfigFunctions `json:"functions,omitempty"`
- Global *ConfigGlobal `json:"global,omitempty"`
- Graphql *ConfigGraphql `json:"graphql,omitempty"`
- Hasura *ConfigHasura `json:"hasura"`
+ // Configuration for graphite service
+ Ai *ConfigAi `json:"ai,omitempty"`
+ // Configuration for auth service
+ Auth *ConfigAuth `json:"auth,omitempty"`
+ // Configuration for functions service
+ Functions *ConfigFunctions `json:"functions,omitempty"`
+ // Global configuration that applies to all services
+ Global *ConfigGlobal `json:"global,omitempty"`
+ // Advanced configuration for GraphQL
+ Graphql *ConfigGraphql `json:"graphql,omitempty"`
+ // Configuration for hasura
+ Hasura *ConfigHasura `json:"hasura"`
+ // Configuration for observability service
Observability *ConfigObservability `json:"observability"`
- Postgres *ConfigPostgres `json:"postgres"`
- Provider *ConfigProvider `json:"provider,omitempty"`
- Storage *ConfigStorage `json:"storage,omitempty"`
+ // Configuration for postgres service
+ Postgres *ConfigPostgres `json:"postgres"`
+ // Configuration for third party providers like SMTP, SMS, etc.
+ Provider *ConfigProvider `json:"provider,omitempty"`
+ // Configuration for storage service
+ Storage *ConfigStorage `json:"storage,omitempty"`
}
type ConfigConfigUpdateInput struct {
@@ -564,7 +607,8 @@ type ConfigConfigUpdateInput struct {
}
type ConfigEnvironmentVariable struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ // Value of the environment variable
Value string `json:"value"`
}
@@ -578,6 +622,7 @@ type ConfigEnvironmentVariableUpdateInput struct {
Value *string `json:"value,omitempty"`
}
+// Configuration for functions service
type ConfigFunctions struct {
Node *ConfigFunctionsNode `json:"node,omitempty"`
RateLimit *ConfigRateLimit `json:"rateLimit,omitempty"`
@@ -606,12 +651,15 @@ type ConfigFunctionsUpdateInput struct {
Resources *ConfigFunctionsResourcesUpdateInput `json:"resources,omitempty"`
}
+// Global configuration that applies to all services
type ConfigGlobal struct {
+ // User-defined environment variables that are spread over all services
Environment []*ConfigGlobalEnvironmentVariable `json:"environment,omitempty"`
}
type ConfigGlobalEnvironmentVariable struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ // Value of the environment variable
Value string `json:"value"`
}
@@ -768,23 +816,34 @@ type ConfigGraphqlUpdateInput struct {
Security *ConfigGraphqlSecurityUpdateInput `json:"security,omitempty"`
}
+// Configuration for hasura service
type ConfigHasura struct {
- AdminSecret string `json:"adminSecret"`
- AuthHook *ConfigHasuraAuthHook `json:"authHook,omitempty"`
- Events *ConfigHasuraEvents `json:"events,omitempty"`
- JwtSecrets []*ConfigJWTSecret `json:"jwtSecrets,omitempty"`
- Logs *ConfigHasuraLogs `json:"logs,omitempty"`
- RateLimit *ConfigRateLimit `json:"rateLimit,omitempty"`
- Resources *ConfigResources `json:"resources,omitempty"`
- Settings *ConfigHasuraSettings `json:"settings,omitempty"`
- Version *string `json:"version,omitempty"`
- WebhookSecret string `json:"webhookSecret"`
+ // Admin secret
+ AdminSecret string `json:"adminSecret"`
+ AuthHook *ConfigHasuraAuthHook `json:"authHook,omitempty"`
+ Events *ConfigHasuraEvents `json:"events,omitempty"`
+ // JWT Secrets configuration
+ JwtSecrets []*ConfigJWTSecret `json:"jwtSecrets,omitempty"`
+ Logs *ConfigHasuraLogs `json:"logs,omitempty"`
+ RateLimit *ConfigRateLimit `json:"rateLimit,omitempty"`
+ // Resources for the service
+ Resources *ConfigResources `json:"resources,omitempty"`
+ // Configuration for hasura services
+ // Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
+ Settings *ConfigHasuraSettings `json:"settings,omitempty"`
+ // Version of hasura, you can see available versions in the URL below:
+ // https://hub.docker.com/r/hasura/graphql-engine/tags
+ Version *string `json:"version,omitempty"`
+ // Webhook secret
+ WebhookSecret string `json:"webhookSecret"`
}
type ConfigHasuraAuthHook struct {
- Mode *string `json:"mode,omitempty"`
- SendRequestBody *bool `json:"sendRequestBody,omitempty"`
- URL string `json:"url"`
+ Mode *string `json:"mode,omitempty"`
+ // HASURA_GRAPHQL_AUTH_HOOK_SEND_REQUEST_BODY
+ SendRequestBody *bool `json:"sendRequestBody,omitempty"`
+ // HASURA_GRAPHQL_AUTH_HOOK
+ URL string `json:"url"`
}
type ConfigHasuraAuthHookUpdateInput struct {
@@ -794,6 +853,7 @@ type ConfigHasuraAuthHookUpdateInput struct {
}
type ConfigHasuraEvents struct {
+ // HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
HTTPPoolSize *uint32 `json:"httpPoolSize,omitempty"`
}
@@ -809,16 +869,27 @@ type ConfigHasuraLogsUpdateInput struct {
Level *string `json:"level,omitempty"`
}
+// Configuration for hasura services
+// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
type ConfigHasuraSettings struct {
- CorsDomain []string `json:"corsDomain,omitempty"`
- DevMode *bool `json:"devMode,omitempty"`
- EnableAllowList *bool `json:"enableAllowList,omitempty"`
- EnableConsole *bool `json:"enableConsole,omitempty"`
- EnableRemoteSchemaPermissions *bool `json:"enableRemoteSchemaPermissions,omitempty"`
- EnabledAPIs []string `json:"enabledAPIs,omitempty"`
- InferFunctionPermissions *bool `json:"inferFunctionPermissions,omitempty"`
- LiveQueriesMultiplexedRefetchInterval *uint32 `json:"liveQueriesMultiplexedRefetchInterval,omitempty"`
- StringifyNumericTypes *bool `json:"stringifyNumericTypes,omitempty"`
+ // HASURA_GRAPHQL_CORS_DOMAIN
+ CorsDomain []string `json:"corsDomain,omitempty"`
+ // HASURA_GRAPHQL_DEV_MODE
+ DevMode *bool `json:"devMode,omitempty"`
+ // HASURA_GRAPHQL_ENABLE_ALLOWLIST
+ EnableAllowList *bool `json:"enableAllowList,omitempty"`
+ // HASURA_GRAPHQL_ENABLE_CONSOLE
+ EnableConsole *bool `json:"enableConsole,omitempty"`
+ // HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
+ EnableRemoteSchemaPermissions *bool `json:"enableRemoteSchemaPermissions,omitempty"`
+ // HASURA_GRAPHQL_ENABLED_APIS
+ EnabledAPIs []string `json:"enabledAPIs,omitempty"`
+ // HASURA_GRAPHQL_INFER_FUNCTION_PERMISSIONS
+ InferFunctionPermissions *bool `json:"inferFunctionPermissions,omitempty"`
+ // HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
+ LiveQueriesMultiplexedRefetchInterval *uint32 `json:"liveQueriesMultiplexedRefetchInterval,omitempty"`
+ // HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES
+ StringifyNumericTypes *bool `json:"stringifyNumericTypes,omitempty"`
}
type ConfigHasuraSettingsUpdateInput struct {
@@ -891,6 +962,7 @@ type ConfigIngressUpdateInput struct {
TLS *ConfigIngressTLSUpdateInput `json:"tls,omitempty"`
}
+// See https://hasura.io/docs/latest/auth/authentication/jwt/
type ConfigJWTSecret struct {
AllowedSkew *uint32 `json:"allowed_skew,omitempty"`
Audience *string `json:"audience,omitempty"`
@@ -939,11 +1011,15 @@ type ConfigObservabilityUpdateInput struct {
Grafana *ConfigGrafanaUpdateInput `json:"grafana,omitempty"`
}
+// Configuration for postgres service
type ConfigPostgres struct {
- Pitr *ConfigPostgresPitr `json:"pitr,omitempty"`
+ Pitr *ConfigPostgresPitr `json:"pitr,omitempty"`
+ // Resources for the service
Resources *ConfigPostgresResources `json:"resources"`
Settings *ConfigPostgresSettings `json:"settings,omitempty"`
- Version *string `json:"version,omitempty"`
+ // Version of postgres, you can see available versions in the URL below:
+ // https://hub.docker.com/r/nhost/postgres/tags
+ Version *string `json:"version,omitempty"`
}
type ConfigPostgresPitr struct {
@@ -954,6 +1030,7 @@ type ConfigPostgresPitrUpdateInput struct {
Retention *uint32 `json:"retention,omitempty"`
}
+// Resources for the service
type ConfigPostgresResources struct {
Compute *ConfigResourcesCompute `json:"compute,omitempty"`
EnablePublicAccess *bool `json:"enablePublicAccess,omitempty"`
@@ -1060,15 +1137,19 @@ type ConfigRateLimitUpdateInput struct {
Limit *uint32 `json:"limit,omitempty"`
}
+// Resource configuration for a service
type ConfigResources struct {
Autoscaler *ConfigAutoscaler `json:"autoscaler,omitempty"`
Compute *ConfigResourcesCompute `json:"compute,omitempty"`
Networking *ConfigNetworking `json:"networking,omitempty"`
- Replicas *uint32 `json:"replicas,omitempty"`
+ // Number of replicas for a service
+ Replicas *uint32 `json:"replicas,omitempty"`
}
type ConfigResourcesCompute struct {
- CPU uint32 `json:"cpu"`
+ // milicpus, 1000 milicpus = 1 cpu
+ CPU uint32 `json:"cpu"`
+ // MiB: 128MiB to 30GiB
Memory uint32 `json:"memory"`
}
@@ -1120,7 +1201,8 @@ type ConfigRunServiceConfigWithID struct {
}
type ConfigRunServiceImage struct {
- Image string `json:"image"`
+ Image string `json:"image"`
+ // content of "auths", i.e., { "auths": $THIS }
PullCredentials *string `json:"pullCredentials,omitempty"`
}
@@ -1158,11 +1240,13 @@ type ConfigRunServicePortUpdateInput struct {
Type *string `json:"type,omitempty"`
}
+// Resource configuration for a service
type ConfigRunServiceResources struct {
- Autoscaler *ConfigAutoscaler `json:"autoscaler,omitempty"`
- Compute *ConfigComputeResources `json:"compute"`
- Replicas uint32 `json:"replicas"`
- Storage []*ConfigRunServiceResourcesStorage `json:"storage,omitempty"`
+ Autoscaler *ConfigAutoscaler `json:"autoscaler,omitempty"`
+ Compute *ConfigComputeResources `json:"compute"`
+ // Number of replicas for a service
+ Replicas uint32 `json:"replicas"`
+ Storage []*ConfigRunServiceResourcesStorage `json:"storage,omitempty"`
}
type ConfigRunServiceResourcesInsertInput struct {
@@ -1173,9 +1257,11 @@ type ConfigRunServiceResourcesInsertInput struct {
}
type ConfigRunServiceResourcesStorage struct {
+ // GiB
Capacity uint32 `json:"capacity"`
- Name string `json:"name"`
- Path string `json:"path"`
+ // name of the volume, changing it will cause data loss
+ Name string `json:"name"`
+ Path string `json:"path"`
}
type ConfigRunServiceResourcesStorageInsertInput struct {
@@ -1259,11 +1345,20 @@ type ConfigStandardOauthProviderWithScopeUpdateInput struct {
Scope []string `json:"scope,omitempty"`
}
+// Configuration for storage service
type ConfigStorage struct {
Antivirus *ConfigStorageAntivirus `json:"antivirus,omitempty"`
RateLimit *ConfigRateLimit `json:"rateLimit,omitempty"`
- Resources *ConfigResources `json:"resources,omitempty"`
- Version *string `json:"version,omitempty"`
+ // Networking (custom domains at the moment) are not allowed as we need to do further
+ // configurations in the CDN. We will enable it again in the future.
+ Resources *ConfigResources `json:"resources,omitempty"`
+ // Version of storage service, you can see available versions in the URL below:
+ // https://hub.docker.com/r/nhost/hasura-storage/tags
+ //
+ // Releases:
+ //
+ // https://github.com/nhost/hasura-storage/releases
+ Version *string `json:"version,omitempty"`
}
type ConfigStorageAntivirus struct {
@@ -1301,6 +1396,8 @@ type ConfigSystemConfigAuthEmailTemplates struct {
}
type ConfigSystemConfigGraphql struct {
+ // manually enable graphi on a per-service basis
+ // by default it follows the plan
FeatureAdvancedGraphql *bool `json:"featureAdvancedGraphql,omitempty"`
}
@@ -1718,7 +1815,8 @@ type Apps struct {
AppStates []*AppStateHistory `json:"appStates"`
AutomaticDeploys bool `json:"automaticDeploys"`
// An array relationship
- Backups []*Backups `json:"backups"`
+ Backups []*Backups `json:"backups"`
+ // main entrypoint to the configuration
Config *ConfigConfig `json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
// An object relationship
@@ -2149,6 +2247,14 @@ type AuthUserProvidersMinOrderBy struct {
ProviderID *OrderBy `json:"providerId,omitempty"`
}
+// response of any mutation on the table "auth.user_providers"
+type AuthUserProvidersMutationResponse struct {
+ // number of rows affected by the mutation
+ AffectedRows int64 `json:"affected_rows"`
+ // data from the rows affected by the mutation
+ Returning []*AuthUserProviders `json:"returning"`
+}
+
// Ordering options when selecting data from "auth.user_providers".
type AuthUserProvidersOrderBy struct {
ID *OrderBy `json:"id,omitempty"`
@@ -2725,6 +2831,7 @@ type Deployments struct {
CommitSha string `json:"commitSHA"`
CommitUserAvatarURL *string `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *string `json:"commitUserName,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
DeploymentEndedAt *time.Time `json:"deploymentEndedAt,omitempty"`
// An array relationship
DeploymentLogs []*DeploymentLogs `json:"deploymentLogs"`
@@ -2767,6 +2874,7 @@ type DeploymentsBoolExp struct {
CommitSha *StringComparisonExp `json:"commitSHA,omitempty"`
CommitUserAvatarURL *StringComparisonExp `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *StringComparisonExp `json:"commitUserName,omitempty"`
+ CreatedAt *TimestamptzComparisonExp `json:"createdAt,omitempty"`
DeploymentEndedAt *TimestamptzComparisonExp `json:"deploymentEndedAt,omitempty"`
DeploymentLogs *DeploymentLogsBoolExp `json:"deploymentLogs,omitempty"`
DeploymentStartedAt *TimestamptzComparisonExp `json:"deploymentStartedAt,omitempty"`
@@ -2801,6 +2909,7 @@ type DeploymentsMaxOrderBy struct {
CommitSha *OrderBy `json:"commitSHA,omitempty"`
CommitUserAvatarURL *OrderBy `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *OrderBy `json:"commitUserName,omitempty"`
+ CreatedAt *OrderBy `json:"createdAt,omitempty"`
DeploymentEndedAt *OrderBy `json:"deploymentEndedAt,omitempty"`
DeploymentStartedAt *OrderBy `json:"deploymentStartedAt,omitempty"`
DeploymentStatus *OrderBy `json:"deploymentStatus,omitempty"`
@@ -2823,6 +2932,7 @@ type DeploymentsMinOrderBy struct {
CommitSha *OrderBy `json:"commitSHA,omitempty"`
CommitUserAvatarURL *OrderBy `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *OrderBy `json:"commitUserName,omitempty"`
+ CreatedAt *OrderBy `json:"createdAt,omitempty"`
DeploymentEndedAt *OrderBy `json:"deploymentEndedAt,omitempty"`
DeploymentStartedAt *OrderBy `json:"deploymentStartedAt,omitempty"`
DeploymentStatus *OrderBy `json:"deploymentStatus,omitempty"`
@@ -2861,6 +2971,7 @@ type DeploymentsOrderBy struct {
CommitSha *OrderBy `json:"commitSHA,omitempty"`
CommitUserAvatarURL *OrderBy `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *OrderBy `json:"commitUserName,omitempty"`
+ CreatedAt *OrderBy `json:"createdAt,omitempty"`
DeploymentEndedAt *OrderBy `json:"deploymentEndedAt,omitempty"`
DeploymentLogsAggregate *DeploymentLogsAggregateOrderBy `json:"deploymentLogs_aggregate,omitempty"`
DeploymentStartedAt *OrderBy `json:"deploymentStartedAt,omitempty"`
@@ -2892,6 +3003,7 @@ type DeploymentsStreamCursorValueInput struct {
CommitSha *string `json:"commitSHA,omitempty"`
CommitUserAvatarURL *string `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *string `json:"commitUserName,omitempty"`
+ CreatedAt *time.Time `json:"createdAt,omitempty"`
DeploymentEndedAt *time.Time `json:"deploymentEndedAt,omitempty"`
DeploymentStartedAt *time.Time `json:"deploymentStartedAt,omitempty"`
DeploymentStatus *string `json:"deploymentStatus,omitempty"`
@@ -6885,6 +6997,8 @@ const (
// column name
DeploymentsSelectColumnCommitUserName DeploymentsSelectColumn = "commitUserName"
// column name
+ DeploymentsSelectColumnCreatedAt DeploymentsSelectColumn = "createdAt"
+ // column name
DeploymentsSelectColumnDeploymentEndedAt DeploymentsSelectColumn = "deploymentEndedAt"
// column name
DeploymentsSelectColumnDeploymentStartedAt DeploymentsSelectColumn = "deploymentStartedAt"
@@ -6918,6 +7032,7 @@ var AllDeploymentsSelectColumn = []DeploymentsSelectColumn{
DeploymentsSelectColumnCommitSha,
DeploymentsSelectColumnCommitUserAvatarURL,
DeploymentsSelectColumnCommitUserName,
+ DeploymentsSelectColumnCreatedAt,
DeploymentsSelectColumnDeploymentEndedAt,
DeploymentsSelectColumnDeploymentStartedAt,
DeploymentsSelectColumnDeploymentStatus,
@@ -6935,7 +7050,7 @@ var AllDeploymentsSelectColumn = []DeploymentsSelectColumn{
func (e DeploymentsSelectColumn) IsValid() bool {
switch e {
- case DeploymentsSelectColumnAppID, DeploymentsSelectColumnCommitMessage, DeploymentsSelectColumnCommitSha, DeploymentsSelectColumnCommitUserAvatarURL, DeploymentsSelectColumnCommitUserName, DeploymentsSelectColumnDeploymentEndedAt, DeploymentsSelectColumnDeploymentStartedAt, DeploymentsSelectColumnDeploymentStatus, DeploymentsSelectColumnFunctionsEndedAt, DeploymentsSelectColumnFunctionsStartedAt, DeploymentsSelectColumnFunctionsStatus, DeploymentsSelectColumnID, DeploymentsSelectColumnMetadataEndedAt, DeploymentsSelectColumnMetadataStartedAt, DeploymentsSelectColumnMetadataStatus, DeploymentsSelectColumnMigrationsEndedAt, DeploymentsSelectColumnMigrationsStartedAt, DeploymentsSelectColumnMigrationsStatus:
+ case DeploymentsSelectColumnAppID, DeploymentsSelectColumnCommitMessage, DeploymentsSelectColumnCommitSha, DeploymentsSelectColumnCommitUserAvatarURL, DeploymentsSelectColumnCommitUserName, DeploymentsSelectColumnCreatedAt, DeploymentsSelectColumnDeploymentEndedAt, DeploymentsSelectColumnDeploymentStartedAt, DeploymentsSelectColumnDeploymentStatus, DeploymentsSelectColumnFunctionsEndedAt, DeploymentsSelectColumnFunctionsStartedAt, DeploymentsSelectColumnFunctionsStatus, DeploymentsSelectColumnID, DeploymentsSelectColumnMetadataEndedAt, DeploymentsSelectColumnMetadataStartedAt, DeploymentsSelectColumnMetadataStatus, DeploymentsSelectColumnMigrationsEndedAt, DeploymentsSelectColumnMigrationsStartedAt, DeploymentsSelectColumnMigrationsStatus:
return true
}
return false
diff --git a/cli/project.nix b/cli/project.nix
index bd9c7e877..de8cdf6a3 100644
--- a/cli/project.nix
+++ b/cli/project.nix
@@ -27,8 +27,9 @@ let
"${submodule}/mcp/nhost/auth/openapi.yaml"
"${submodule}/mcp/nhost/graphql/openapi.yaml"
- "${submodule}/mcp/tools/cloud/schema.graphql"
- "${submodule}/mcp/tools/cloud/schema-with-mutations.graphql"
+ "${submodule}/mcp/resources/cloud_schema.graphql"
+ "${submodule}/mcp/resources/cloud_schema-with-mutations.graphql"
+ "${submodule}/mcp/resources/nhost_toml_schema.cue"
(inDirectory "${submodule}/cmd/mcp/testdata")
(inDirectory "${submodule}/mcp/graphql/testdata")
];
diff --git a/cli/ssl/.ssl/local-fullchain.pem b/cli/ssl/.ssl/local-fullchain.pem
index 5a1077f23..20d6117f8 100644
--- a/cli/ssl/.ssl/local-fullchain.pem
+++ b/cli/ssl/.ssl/local-fullchain.pem
@@ -1,27 +1,27 @@
-----BEGIN CERTIFICATE-----
-MIIERDCCA8mgAwIBAgISBmRex3kpZ4Mz1/1kq05iqja/MAoGCCqGSM49BAMDMDIx
+MIIERTCCA8ugAwIBAgISBWD/E+b14mP5jv4DGWRVYv8fMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
-ODAeFw0yNTEwMDIxMDUxNDBaFw0yNTEyMzExMDUxMzlaMB8xHTAbBgNVBAMTFGxv
-Y2FsLmF1dGgubmhvc3QucnVuMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2cVM
-ojf8iXZGLneNfnke5LMJIxyTEeGbNOfCv4SOR4K/N4OkpvkUVbH2bRvX99uE9jaK
-515Y48PzPA/4+W1zTKOCAtAwggLMMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAU
-BggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUQqan
-raZoU5klAxsgkEVEMIkxmMQwHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNy
+ODAeFw0yNTExMDYxMDUxMTBaFw0yNjAyMDQxMDUxMDlaMB8xHTAbBgNVBAMTFGxv
+Y2FsLmF1dGgubmhvc3QucnVuMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOah5
+ZLuUQp3pdMBxBWnT6E6/amW9LerKKEEdy3Nc8iAwG9LlnPH0z3m7a9wgEhpFEdlL
+Rr+qO+NhSRnv6+UF5KOCAtIwggLOMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAU
+BggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUGyb1
+TVK/0vf3uHO4x3R094aG2rEwHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNy
kcowMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRwOi8vZTguaS5sZW5j
ci5vcmcvMIHOBgNVHREEgcYwgcOCFGxvY2FsLmF1dGgubmhvc3QucnVughlsb2Nh
bC5kYXNoYm9hcmQubmhvc3QucnVughJsb2NhbC5kYi5uaG9zdC5ydW6CGWxvY2Fs
LmZ1bmN0aW9ucy5uaG9zdC5ydW6CF2xvY2FsLmdyYXBocWwubmhvc3QucnVughZs
b2NhbC5oYXN1cmEubmhvc3QucnVughdsb2NhbC5tYWlsaG9nLm5ob3N0LnJ1boIX
bG9jYWwuc3RvcmFnZS5uaG9zdC5ydW4wEwYDVR0gBAwwCjAIBgZngQwBAgEwLQYD
-VR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVuY3Iub3JnLzY0LmNybDCCAQIG
-CisGAQQB1nkCBAIEgfMEgfAA7gB1AO08S9boBsKkogBX28sk4jgB31Ev7cSGxXAP
-IN23Pj/gAAABmaTCI4YAAAQDAEYwRAIgXLRFL1EAXfvN6kd5m6udqlxfz4+5B6rq
-Cdhp/ZwDAZ8CIFYvalTkl5NEBEMD3vpPvrj8s1Yy2xsropEh/AvpavvLAHUAGYbU
-xyiqb/66A294Kk0BkarOLXIxD67OXXBBLSVMx9QAAAGZpMIjhwAABAMARjBEAiBk
-H1vqU9HNuBcf4UYL/xZ42BeUAARHStiFaIZtnR1kEgIgbIJ0CGqIpxmWuwCunl9p
-ar+rGLdQrCk9BZXq/VjPPAAwCgYIKoZIzj0EAwMDaQAwZgIxAKvk5a2zQsv7JLNj
-NO1ly+DI8qiy5nf4HQrOrHOjtmx5RUu0HSO9P0J0u069qAqXMgIxAMLdME9JUo2c
-TJo3pwWv5MRyg/MkOJ4ImKdDJXfIZNkEIUyP3vwTqImvZe07gJDsYg==
+VR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVuY3Iub3JnLzMyLmNybDCCAQQG
+CisGAQQB1nkCBAIEgfUEgfIA8AB2ABmG1Mcoqm/+ugNveCpNAZGqzi1yMQ+uzl1w
+QS0lTMfUAAABmlkAQokAAAQDAEcwRQIgWDtSxJfM2xcjvScVHOkn8bipzBhNhTnm
+B89TDh1/4XUCIQDe08W33PCx2D+akCdW9U9mZKQpIW6deLZSI3ZWpSNKMAB2AA5X
+lLzzrqk+MxssmQez95Dfm8I9cTIl3SGpJaxhxU4hAAABmlkAQn8AAAQDAEcwRQIg
+KnojmNTpNk1OFTQI0EnlPa2bpwqmUgmUCLeqE6SWfgoCIQCrhZbxYPHbGLF/HpRq
+vCTcOh24SRCuxlkqtaowbbfmKjAKBggqhkjOPQQDAwNoADBlAjEArstFIC+KAsfQ
+nLhtqsaNzkhftN5adDyr2CoE0WUPF1sLDi+xDnDO+JgIPL0YKAFNAjATJ4omhpc+
+I6/kWcef2RyO9YCGQQE9pdez5CYKb9o8YAntDSHM3b5nXXj3AX/USdQ=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
diff --git a/cli/ssl/.ssl/local-privkey.pem b/cli/ssl/.ssl/local-privkey.pem
index bc44190a0..6e24f8902 100644
--- a/cli/ssl/.ssl/local-privkey.pem
+++ b/cli/ssl/.ssl/local-privkey.pem
@@ -1,5 +1,5 @@
-----BEGIN PRIVATE KEY-----
-MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgfJZOkvawA0vBMw9W
-ph8i1Z+SJQrFscPbqSYpxngzEDahRANCAATZxUyiN/yJdkYud41+eR7kswkjHJMR
-4Zs058K/hI5Hgr83g6Sm+RRVsfZtG9f324T2NornXljjw/M8D/j5bXNM
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgInXN4JRnXNTjx7rM
+avurZrN1EV1iebQeNUlMlFp7VJ+hRANCAAQ5qHlku5RCnel0wHEFadPoTr9qZb0t
+6sooQR3Lc1zyIDAb0uWc8fTPebtr3CASGkUR2UtGv6o742FJGe/r5QXk
-----END PRIVATE KEY-----
diff --git a/cli/ssl/.ssl/sub-fullchain.pem b/cli/ssl/.ssl/sub-fullchain.pem
index cba65d045..0b0239912 100644
--- a/cli/ssl/.ssl/sub-fullchain.pem
+++ b/cli/ssl/.ssl/sub-fullchain.pem
@@ -1,52 +1,52 @@
-----BEGIN CERTIFICATE-----
-MIIEWDCCA96gAwIBAgISBbvrSsjDQm4zevwwjxFGmeTMMAoGCCqGSM49BAMDMDIx
+MIIEVzCCA92gAwIBAgISBm54VdkoqD8s8efq7ceHaTihMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
-NzAeFw0yNTEwMDIxMDUyNTdaFw0yNTEyMzExMDUyNTZaMCExHzAdBgNVBAMMFiou
-YXV0aC5sb2NhbC5uaG9zdC5ydW4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATG
-x0o7t0pSrOoFc+pljtqJVxgaSW+w9D9C2WdysMeSKKOU+0MzaM4ynLUhETOpBs8E
-612mdcoeak+G1Emj6UVwo4IC4zCCAt8wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQW
-MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQ+
-lVsLiXSRLAECs9OgkCEBS7jMmzAfBgNVHSMEGDAWgBSuSJ7chx1EoG/aouVgdAR4
-wpwAgDAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNy5pLmxl
+ODAeFw0yNTExMDYxMDUyMjBaFw0yNjAyMDQxMDUyMTlaMCExHzAdBgNVBAMMFiou
+YXV0aC5sb2NhbC5uaG9zdC5ydW4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASI
+rTkZOM4ip42DCyDADXGc7oV3+OkimyTM3st2RIZWG28rFRwH0LebJV2cduq1Hdtl
+VxIEr+RhvyIL7gllueXUo4IC4jCCAt4wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQW
+MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTw
+bM86O381+aljU3oTUvwhZ90PCDAfBgNVHSMEGDAWgBSPDROi9i5+0VBsMxg4XVmO
+I3KRyjAyBggrBgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lOC5pLmxl
bmNyLm9yZy8wgd4GA1UdEQSB1jCB04IWKi5hdXRoLmxvY2FsLm5ob3N0LnJ1boIb
Ki5kYXNoYm9hcmQubG9jYWwubmhvc3QucnVughQqLmRiLmxvY2FsLm5ob3N0LnJ1
boIbKi5mdW5jdGlvbnMubG9jYWwubmhvc3QucnVughkqLmdyYXBocWwubG9jYWwu
bmhvc3QucnVughgqLmhhc3VyYS5sb2NhbC5uaG9zdC5ydW6CGSoubWFpbGhvZy5s
b2NhbC5uaG9zdC5ydW6CGSouc3RvcmFnZS5sb2NhbC5uaG9zdC5ydW4wEwYDVR0g
-BAwwCjAIBgZngQwBAgEwLQYDVR0fBCYwJDAioCCgHoYcaHR0cDovL2U3LmMubGVu
-Y3Iub3JnLzc3LmNybDCCAQUGCisGAQQB1nkCBAIEgfYEgfMA8QB2AN3cyjSV1+EW
-BeeVMvrHn/g9HFDf2wA6FBJ2Ciysu8gqAAABmaTDUHkAAAQDAEcwRQIgWudJ8XKA
-BT5jq5Tl0xQLNb953pBi22Tb0TIWk+RSqHgCIQDsTrLVMFaQTV7EFCY1tFhi5qae
-SCpEwwdFcnom/nz6EAB3AO08S9boBsKkogBX28sk4jgB31Ev7cSGxXAPIN23Pj/g
-AAABmaTDWAsAAAQDAEgwRgIhALxIgIiutEwgNcGw7/cAdjFqUugct4HlZezIOLLP
-rg69AiEA8YCaK41rJDYztEKUIJEq2J2ktSqGYcl9gNKC+SiR4acwCgYIKoZIzj0E
-AwMDaAAwZQIwVG9yOiMRfKFFyFj1R8X/5U67QD84OhZ0oM0SZsVhezLedG5b8eFf
-/cWraREi8xbFAjEA/6RXweGzl08F7EtqBDoiqitScI2rbwGtP6s/evL0zXTABZD2
-ih7AGxjtg80IqIRe
+BAwwCjAIBgZngQwBAgEwLQYDVR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVu
+Y3Iub3JnLzM0LmNybDCCAQQGCisGAQQB1nkCBAIEgfUEgfIA8AB2AEmcm2neHXzs
+/DbezYdkprhbrwqHgBnRVVL76esp3fjDAAABmlkBVgkAAAQDAEcwRQIhANH6Ml3u
+IM4nAzwAIjIjBjn8EWbn1ZHfgwO+rlSo5rzpAiATPKE8Mx5LK1IayG5VCK1eCDyc
+rzt1HNbP9WSrpuHx+gB2ABmG1Mcoqm/+ugNveCpNAZGqzi1yMQ+uzl1wQS0lTMfU
+AAABmlkBVgcAAAQDAEcwRQIgIT/DhsIj9Aw7qf/2lknJCr907dEqC3/+QN3zlcOj
+iKoCIQCTguinYjJPZwU2dblaRQ2q7MTCMT2ZENExltxwYG3GzjAKBggqhkjOPQQD
+AwNoADBlAjEA5nFoNrLyeC079YpRvdah/HZIA/lUBh+LOo/NcEBD3aTGs2z8hU8z
+H4vMy3OnfQ9TAjBxigm7zE5/3CAcGoSOr/P0TL52nh+lO4SUVxcbKgYB8A2yo6o/
+kUkG7PiRB0uUpNw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
-MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
-TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
-cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
-WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
-RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
-CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
-QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
-gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
-ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
-wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
-AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
-BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
-Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
-aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
-h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
-yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
-OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
-yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
-M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
-UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
-Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
-tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
-YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
-+VUwFj9tmWxyR/M=
+MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
+MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
+Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa
+Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF
+bmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7c
+S7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHb
+R6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB
+9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB
+MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4j
+cpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB
+BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE
+DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j
+ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0
+RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98d
+AQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8
+otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVA
+aDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpm
+Uwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2
+HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1
+Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeR
+xs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3d
+tA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/
+jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jS
+u1igv3OefnWjSQ==
-----END CERTIFICATE-----
diff --git a/cli/ssl/.ssl/sub-privkey.pem b/cli/ssl/.ssl/sub-privkey.pem
index 68ae61a09..8aef383ec 100644
--- a/cli/ssl/.ssl/sub-privkey.pem
+++ b/cli/ssl/.ssl/sub-privkey.pem
@@ -1,5 +1,5 @@
-----BEGIN PRIVATE KEY-----
-MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrfNUSjLV/7j7LSBf
-zL/hvGEuv+uvf3/aimqjecO7vcShRANCAATGx0o7t0pSrOoFc+pljtqJVxgaSW+w
-9D9C2WdysMeSKKOU+0MzaM4ynLUhETOpBs8E612mdcoeak+G1Emj6UVw
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcrhROXQT85e+S8h8
+RE3Z7TPo3+WA2RmzJsXJbXkbi5qhRANCAASIrTkZOM4ip42DCyDADXGc7oV3+Oki
+myTM3st2RIZWG28rFRwH0LebJV2cduq1HdtlVxIEr+RhvyIL7gllueXU
-----END PRIVATE KEY-----
diff --git a/dashboard/.env.example b/dashboard/.env.example
index d32a65cad..b55b0f521 100644
--- a/dashboard/.env.example
+++ b/dashboard/.env.example
@@ -3,12 +3,13 @@ NEXT_PUBLIC_ENV=dev
NEXT_PUBLIC_NHOST_PLATFORM=false
# Environment Variables for Self Hosting and Local Development
-NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.local.run/v1
+NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.local.nhost.run/v1
+NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=https://local.dashboard.local.nhost.run/v1/configserver/graphql
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
-NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
-NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
+NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run/console
+NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/apis/migrate
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
@@ -18,13 +19,13 @@ NEXT_PUBLIC_ANALYTICS_WRITE_KEY=, 'prefix'> {
prefix?: React.ReactNode;
+ wrapperClassName?: string;
}
const Input = React.forwardRef(
- ({ className, type, prefix, ...props }, ref) => {
+ ({ className, type, prefix, wrapperClassName, ...props }, ref) => {
return (
-
+
{prefix && (
{prefix}
@@ -19,7 +20,7 @@ const Input = React.forwardRef(
{
container?: HTMLElement | null;
hideCloseButton?: boolean;
+ showOverlay?: boolean;
}
const SheetContent = React.forwardRef<
@@ -67,11 +68,13 @@ const SheetContent = React.forwardRef<
container = null,
hideCloseButton,
children,
+ showOverlay = false,
...props
},
ref,
) => (
+ {showOverlay && }
{
className?: string;
children?: React.ReactNode;
+ wrapperClassName?: string;
}
export function Spinner({
@@ -40,10 +41,12 @@ export function Spinner({
show,
children,
className,
+ wrapperClassName,
}: SpinnerContentProps) {
return (
-
+
signInWithGithub()}
diff --git a/dashboard/src/features/auth/AuthProviders/Github/hooks/useGithubAuthentication/useGithubAuthentication.ts b/dashboard/src/features/auth/AuthProviders/Github/hooks/useGithubAuthentication/useGithubAuthentication.ts
index c31e5df87..c08c50eb1 100644
--- a/dashboard/src/features/auth/AuthProviders/Github/hooks/useGithubAuthentication/useGithubAuthentication.ts
+++ b/dashboard/src/features/auth/AuthProviders/Github/hooks/useGithubAuthentication/useGithubAuthentication.ts
@@ -30,8 +30,8 @@ function useGithubAuthentication({
};
}
- const redirectURl = nhost.auth.signInProviderURL('github', options);
- window.location.href = redirectURl;
+ const redirectURL = nhost.auth.signInProviderURL('github', options);
+ window.location.href = redirectURL;
},
{
onError: () => {
diff --git a/dashboard/src/features/auth/SignIn/SignInWithEmailAndPassword/components/SignInWithEmailAndPasswordForm.tsx b/dashboard/src/features/auth/SignIn/SignInWithEmailAndPassword/components/SignInWithEmailAndPasswordForm.tsx
index 21de6cbcf..c7550a089 100644
--- a/dashboard/src/features/auth/SignIn/SignInWithEmailAndPassword/components/SignInWithEmailAndPasswordForm.tsx
+++ b/dashboard/src/features/auth/SignIn/SignInWithEmailAndPassword/components/SignInWithEmailAndPasswordForm.tsx
@@ -24,12 +24,14 @@ function SignInWithEmailAndPassword({ onSubmit, isLoading }: Props) {
label="Email"
name="email"
type="email"
+ placeholder="Email"
/>
-
+
-
+
diff --git a/dashboard/src/features/orgs/components/billing/BillingEstimate/components/BillingDetails/BillingDetails.tsx b/dashboard/src/features/orgs/components/billing/BillingEstimate/components/BillingDetails/BillingDetails.tsx
index cc5d4bc51..7d2b0242c 100644
--- a/dashboard/src/features/orgs/components/billing/BillingEstimate/components/BillingDetails/BillingDetails.tsx
+++ b/dashboard/src/features/orgs/components/billing/BillingEstimate/components/BillingDetails/BillingDetails.tsx
@@ -52,7 +52,7 @@ export default function BillingDetails() {
-
+
Item
@@ -72,7 +72,7 @@ export default function BillingDetails() {
))}
-
+
Total
diff --git a/dashboard/src/features/orgs/components/projects/projects-grid/projects-grid.tsx b/dashboard/src/features/orgs/components/projects/projects-grid/projects-grid.tsx
index 0716228d2..b655ceede 100644
--- a/dashboard/src/features/orgs/components/projects/projects-grid/projects-grid.tsx
+++ b/dashboard/src/features/orgs/components/projects/projects-grid/projects-grid.tsx
@@ -62,7 +62,7 @@ export default function ProjectsGrid({ projects }: ProjectGridProps) {
);
return (
-
+
-
{filteredProjects.map((project) => (
diff --git a/dashboard/src/features/orgs/layout/OrgLayout/PausedProjectContent.tsx b/dashboard/src/features/orgs/layout/OrgLayout/PausedProjectContent.tsx
new file mode 100644
index 000000000..68d383b58
--- /dev/null
+++ b/dashboard/src/features/orgs/layout/OrgLayout/PausedProjectContent.tsx
@@ -0,0 +1,49 @@
+import { ApplicationPaused } from '@/features/orgs/projects/common/components/ApplicationPaused';
+import { ApplicationPausedBanner } from '@/features/orgs/projects/common/components/ApplicationPausedBanner';
+import { useRouter } from 'next/router';
+import { type PropsWithChildren } from 'react';
+
+const baseProjectPageRoute = '/orgs/[orgSlug]/projects/[appSubdomain]/';
+const blockedPausedProjectPages = [
+ 'database',
+ 'database/browser/[dataSourceSlug]',
+ 'graphql',
+ 'graphql/remote-schemas',
+ 'graphql/remote-schemas/[remoteSchemaSlug]',
+ 'hasura',
+ 'users',
+ 'storage',
+ 'ai/auto-embeddings',
+ 'ai/assistants',
+ 'metrics',
+].map((page) => baseProjectPageRoute.concat(page));
+
+function PausedProjectContent({ children }: PropsWithChildren) {
+ const { route } = useRouter();
+
+ const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
+
+ if (isOnOverviewPage) {
+ return (
+ <>
+
+
+
+ {children}
+ >
+ );
+ }
+
+ // block these pages when the project is paused
+ if (blockedPausedProjectPages.includes(route)) {
+ return ;
+ }
+
+ return children;
+}
+
+export default PausedProjectContent;
diff --git a/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.test.tsx b/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.test.tsx
index f51ee6eba..0a94529a8 100644
--- a/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.test.tsx
+++ b/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.test.tsx
@@ -1,5 +1,8 @@
import { mockApplication } from '@/tests/mocks';
-import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
+import {
+ getProjectQuery,
+ getProjectStateQuery,
+} from '@/tests/msw/mocks/graphql/getProjectQuery';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import {
createGraphqlMockResolver,
@@ -29,7 +32,7 @@ function TestComponent() {
);
}
-const server = setupServer(tokenQuery);
+const server = setupServer(tokenQuery, getProjectStateQuery());
const getUseRouterObject = (
route: string = '/orgs/[orgSlug]/projects/[appSubdomain]',
diff --git a/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.tsx b/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.tsx
index 0a5424b23..52dd646cc 100644
--- a/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.tsx
+++ b/dashboard/src/features/orgs/layout/OrgLayout/ProjectLayoutContent.tsx
@@ -1,25 +1,17 @@
import { type AuthenticatedLayoutProps } from '@/components/layout/AuthenticatedLayout';
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
-import { Alert } from '@/components/ui/v2/Alert';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
-import { ApplicationPaused } from '@/features/orgs/projects/common/components/ApplicationPaused';
-import { ApplicationPausedBanner } from '@/features/orgs/projects/common/components/ApplicationPausedBanner';
-import { ApplicationProvisioning } from '@/features/orgs/projects/common/components/ApplicationProvisioning';
-import { ApplicationRestoring } from '@/features/orgs/projects/common/components/ApplicationRestoring';
-import { ApplicationUnknown } from '@/features/orgs/projects/common/components/ApplicationUnknown';
-import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
-import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
-import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
-import { isEmptyValue } from '@/lib/utils';
+import { useProject } from '@/features/orgs/projects/hooks/useProject';
+import { isEmptyValue, isNotEmptyValue } from '@/lib/utils';
import { useAuth } from '@/providers/Auth';
-import { ApplicationStatus } from '@/types/application';
import { getConfigServerUrl, isPlatform as isPlatformFn } from '@/utils/env';
import { NextSeo } from 'next-seo';
import { useRouter } from 'next/router';
-import { useCallback, useEffect, useMemo, type ReactNode } from 'react';
+import { useEffect } from 'react';
import { twMerge } from 'tailwind-merge';
+import ProjectViewWithState from './ProjectViewWithState';
const platFormOnlyPages = [
'/orgs/[orgSlug]/projects/[appSubdomain]/deployments',
@@ -56,111 +48,15 @@ function ProjectLayoutContent({
children,
mainContainerProps = {},
}: ProjectLayoutContentProps) {
- const {
- route,
- query: { appSubdomain },
- push,
- } = useRouter();
+ const { route, push } = useRouter();
- const { state } = useAppState();
const isPlatform = useIsPlatform();
- const { project, loading, error, projectNotFound } = useProjectWithState();
+ const { project, loading, error, projectNotFound } = useProject();
const { isAuthenticated, isLoading, isSigningOut } = useAuth();
const isUserLoggedIn = isAuthenticated && !isLoading && !isSigningOut;
- const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
-
- const renderPausedProjectContent = useCallback(
- (_children: ReactNode) => {
- const baseProjectPageRoute = '/orgs/[orgSlug]/projects/[appSubdomain]/';
- const blockedPausedProjectPages = [
- 'database',
- 'database/browser/[dataSourceSlug]',
- 'graphql',
- 'hasura',
- 'users',
- 'storage',
- 'ai/auto-embeddings',
- 'ai/assistants',
- 'metrics',
- ].map((page) => baseProjectPageRoute.concat(page));
-
- // show an alert box on top of the overview page with a wake up button
- if (isOnOverviewPage) {
- return (
- <>
-
-
-
- {children}
- >
- );
- }
-
- // block these pages when the project is paused
- if (blockedPausedProjectPages.includes(route)) {
- return ;
- }
-
- return _children;
- },
- [route, isOnOverviewPage, children],
- );
-
- // Render application state based on the current state
- const projectPageContent = useMemo(() => {
- if (!appSubdomain || state === undefined) {
- return children;
- }
-
- switch (state) {
- case ApplicationStatus.Empty:
- case ApplicationStatus.Provisioning:
- return ;
- case ApplicationStatus.Errored:
- if (isOnOverviewPage) {
- return (
- <>
-
-
- Error deploying the project most likely due to invalid
- configuration. Please review your project's configuration
- and logs for more information.
-
-
- {children}
- >
- );
- }
- return children;
- case ApplicationStatus.Pausing:
- case ApplicationStatus.Paused:
- return renderPausedProjectContent(children);
- case ApplicationStatus.Unpausing:
- return ;
- case ApplicationStatus.Restoring:
- return ;
- case ApplicationStatus.Updating:
- case ApplicationStatus.Live:
- case ApplicationStatus.Migrating:
- return children;
- default:
- return ;
- }
- }, [
- state,
- children,
- appSubdomain,
- isOnOverviewPage,
- renderPausedProjectContent,
- ]);
-
useEffect(() => {
if (
isPlatformOnlyPage(route) ||
@@ -179,13 +75,11 @@ function ProjectLayoutContent({
return null;
}
- // Handle loading state
if (loading) {
return ;
}
- // Handle error state
- if (error) {
+ if (isNotEmptyValue(error)) {
throw error;
}
@@ -207,7 +101,7 @@ function ProjectLayoutContent({
)}
{...mainContainerProps}
>
- {projectPageContent}
+ {children}
);
diff --git a/dashboard/src/features/orgs/layout/OrgLayout/ProjectViewWithState.test.tsx b/dashboard/src/features/orgs/layout/OrgLayout/ProjectViewWithState.test.tsx
new file mode 100644
index 000000000..731b5e8af
--- /dev/null
+++ b/dashboard/src/features/orgs/layout/OrgLayout/ProjectViewWithState.test.tsx
@@ -0,0 +1,245 @@
+import {
+ getProjectQuery,
+ getProjectStateQuery,
+} from '@/tests/msw/mocks/graphql/getProjectQuery';
+import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
+import { queryClient, render, screen } from '@/tests/testUtils';
+import { ApplicationStatus } from '@/types/application';
+import { setupServer } from 'msw/node';
+import { vi } from 'vitest';
+import ProjectViewWithState from './ProjectViewWithState';
+
+const mocks = vi.hoisted(() => ({
+ useRouter: vi.fn(),
+ push: vi.fn(),
+}));
+
+vi.mock('next/router', () => ({
+ useRouter: mocks.useRouter,
+}));
+
+vi.mock(
+ '@/features/orgs/projects/common/components/ApplicationProvisioning',
+ () => ({
+ ApplicationProvisioning: () => Application Provisioning,
+ }),
+);
+
+vi.mock(
+ '@/features/orgs/projects/common/components/ApplicationRestoring',
+ () => ({
+ ApplicationRestoring: () => (
+ Application Restoring
+ ),
+ }),
+);
+
+vi.mock(
+ '@/features/orgs/projects/common/components/ApplicationUnknown',
+ () => ({
+ ApplicationUnknown: () => (
+ Application Unknown
+ ),
+ }),
+);
+
+vi.mock(
+ '@/features/orgs/projects/common/components/ApplicationUnpausing',
+ () => ({
+ ApplicationUnpausing: () => (
+ Application Unpausing
+ ),
+ }),
+);
+
+vi.mock(
+ '@/features/orgs/projects/common/components/ApplicationPausedBanner',
+ () => ({
+ ApplicationPausedBanner: () => (
+ Application Banner
+ ),
+ }),
+);
+
+const getUseRouterObject = (
+ route: string = '/orgs/[orgSlug]/projects/[appSubdomain]',
+) => ({
+ basePath: '',
+ pathname: '/orgs/xyz/projects/test-project',
+ route,
+ asPath: '/orgs/xyz/projects/test-project',
+ isLocaleDomain: false,
+ isReady: true,
+ isPreview: false,
+ query: {
+ orgSlug: 'xyz',
+ appSubdomain: 'test-project',
+ },
+ push: mocks.push,
+ replace: vi.fn(),
+ reload: vi.fn(),
+ back: vi.fn(),
+ prefetch: vi.fn(),
+ beforePopState: vi.fn(),
+ events: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+ isFallback: false,
+});
+
+function TestComponent() {
+ return (
+
+ Application content
+
+ );
+}
+
+const server = setupServer(tokenQuery);
+
+describe('ProjectViewWithState', () => {
+ beforeAll(() => {
+ process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
+ process.env.NEXT_PUBLIC_ENV = 'production';
+ server.listen();
+ });
+
+ beforeEach(() => {
+ server.resetHandlers();
+ });
+
+ afterEach(() => {
+ queryClient.clear();
+ mocks.useRouter.mockRestore();
+ mocks.push.mockRestore();
+ vi.restoreAllMocks();
+ });
+
+ it('should render the nothing when the state is empty', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Empty }]));
+ render( );
+ expect(screen.queryByText('Application content')).not.toBeInTheDocument();
+ });
+
+ it('should render the application in provisioning state', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(
+ getProjectStateQuery([{ stateId: ApplicationStatus.Provisioning }]),
+ );
+ render( );
+ expect(
+ await screen.findByText('Application Provisioning'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Application content')).not.toBeInTheDocument();
+ });
+
+ it('should render the application in pausing state', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Pausing }]));
+ render( );
+ expect(await screen.findByText('Application content')).toBeInTheDocument();
+ expect(await screen.findByText('Application Banner')).toBeInTheDocument();
+ });
+
+ it('should render the application Unpausing application state', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(
+ getProjectStateQuery([{ stateId: ApplicationStatus.Unpausing }]),
+ );
+ render( );
+ expect(screen.queryByText('Application content')).not.toBeInTheDocument();
+ expect(
+ await screen.findByText('Application Unpausing'),
+ ).toBeInTheDocument();
+ });
+
+ it('should render the application paused application state', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Paused }]));
+ render( );
+ expect(await screen.findByText('Application content')).toBeInTheDocument();
+ expect(await screen.findByText('Application Banner')).toBeInTheDocument();
+ });
+
+ it('should render the application when the state is updating', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Updating }]));
+ render( );
+
+ expect(await screen.findByText('Application content')).toBeInTheDocument();
+
+ expect(screen.queryByText('Application Restoring')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unknown')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unpausing')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Banner')).not.toBeInTheDocument();
+ });
+
+ it('should render the application when the state is live', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Live }]));
+ render( );
+
+ expect(await screen.findByText('Application content')).toBeInTheDocument();
+
+ expect(screen.queryByText('Application Restoring')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unknown')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unpausing')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Banner')).not.toBeInTheDocument();
+ });
+
+ it('should render the application when the state is migrating', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(
+ getProjectStateQuery([{ stateId: ApplicationStatus.Migrating }]),
+ );
+ render( );
+
+ expect(await screen.findByText('Application content')).toBeInTheDocument();
+
+ expect(screen.queryByText('Application Restoring')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unknown')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unpausing')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Banner')).not.toBeInTheDocument();
+ });
+
+ it('should render the application in an error state', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Errored }]));
+ render( );
+
+ expect(await screen.findByText('Application content')).toBeInTheDocument();
+
+ expect(await screen.findByText(/Error deploying/)).toBeInTheDocument();
+
+ expect(screen.queryByText('Application Restoring')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unknown')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Unpausing')).not.toBeInTheDocument();
+ expect(screen.queryByText('Application Banner')).not.toBeInTheDocument();
+ });
+
+ it('should render the application in an error state', async () => {
+ mocks.useRouter.mockImplementation(() => getUseRouterObject());
+ server.use(getProjectQuery);
+ server.use(
+ getProjectStateQuery([{ stateId: ApplicationStatus.Restoring }]),
+ );
+ render( );
+
+ expect(
+ await screen.findByText('Application Restoring'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Application content')).not.toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/features/orgs/layout/OrgLayout/ProjectViewWithState.tsx b/dashboard/src/features/orgs/layout/OrgLayout/ProjectViewWithState.tsx
new file mode 100644
index 000000000..8275b72d5
--- /dev/null
+++ b/dashboard/src/features/orgs/layout/OrgLayout/ProjectViewWithState.tsx
@@ -0,0 +1,68 @@
+import { Alert } from '@/components/ui/v2/Alert';
+import { ApplicationProvisioning } from '@/features/orgs/projects/common/components/ApplicationProvisioning';
+import { ApplicationRestoring } from '@/features/orgs/projects/common/components/ApplicationRestoring';
+import { ApplicationUnknown } from '@/features/orgs/projects/common/components/ApplicationUnknown';
+import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
+import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
+import { ApplicationStatus } from '@/types/application';
+import { useRouter } from 'next/router';
+import { type PropsWithChildren, useMemo } from 'react';
+
+import PausedProjectContent from './PausedProjectContent';
+
+function ProjectViewWithState({ children }: PropsWithChildren) {
+ const {
+ query: { appSubdomain },
+ route,
+ } = useRouter();
+
+ const { state } = useAppState();
+
+ const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
+
+ const projectPageContent = useMemo(() => {
+ if (!appSubdomain || state === undefined) {
+ return children;
+ }
+
+ switch (state) {
+ case ApplicationStatus.Empty:
+ return null;
+ case ApplicationStatus.Provisioning:
+ return ;
+ case ApplicationStatus.Errored:
+ if (isOnOverviewPage) {
+ return (
+ <>
+
+
+ Error deploying the project most likely due to invalid
+ configuration. Please review your project's configuration
+ and logs for more information.
+
+
+ {children}
+ >
+ );
+ }
+ return children;
+ case ApplicationStatus.Pausing:
+ case ApplicationStatus.Paused:
+ return {children} ;
+ case ApplicationStatus.Unpausing:
+ return ;
+ case ApplicationStatus.Restoring:
+ return ;
+ case ApplicationStatus.Updating:
+ case ApplicationStatus.Live:
+ case ApplicationStatus.Migrating:
+ return children;
+ default:
+ return ;
+ }
+ }, [state, children, appSubdomain, isOnOverviewPage]);
+
+ return projectPageContent;
+}
+
+export default ProjectViewWithState;
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/AppleProviderSettings/AppleProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/AppleProviderSettings/AppleProviderSettings.tsx
index 11de1324a..ad3af0270 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/AppleProviderSettings/AppleProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/AppleProviderSettings/AppleProviderSettings.tsx
@@ -176,7 +176,7 @@ export default function AppleProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-apple"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-apple"
docsTitle="how to sign in users with Apple"
icon={
theme.palette.mode === 'dark'
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/DiscordProviderSettings/DiscordProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/DiscordProviderSettings/DiscordProviderSettings.tsx
index 2f3af45e3..30d330535 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/DiscordProviderSettings/DiscordProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/DiscordProviderSettings/DiscordProviderSettings.tsx
@@ -141,7 +141,7 @@ export default function DiscordProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-discord"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-discord"
docsTitle="how to sign in users with Discord"
icon="/assets/brands/discord.svg"
switchId="enabled"
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/FacebookProviderSettings/FacebookProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/FacebookProviderSettings/FacebookProviderSettings.tsx
index 58407cf96..653c37adf 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/FacebookProviderSettings/FacebookProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/FacebookProviderSettings/FacebookProviderSettings.tsx
@@ -142,7 +142,7 @@ export default function FacebookProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-facebook"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-facebook"
docsTitle="how to sign in users with Facebook"
icon="/assets/brands/facebook.svg"
switchId="enabled"
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/GitHubProviderSettings/GitHubProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/GitHubProviderSettings/GitHubProviderSettings.tsx
index b1fb90147..a56cad899 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/GitHubProviderSettings/GitHubProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/GitHubProviderSettings/GitHubProviderSettings.tsx
@@ -144,7 +144,7 @@ export default function GitHubProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-github"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-github"
docsTitle="how to sign in users with GitHub"
icon={
theme.palette.mode === 'dark'
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/GoogleProviderSettings/GoogleProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/GoogleProviderSettings/GoogleProviderSettings.tsx
index 49fa76f13..ee97bea2e 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/GoogleProviderSettings/GoogleProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/GoogleProviderSettings/GoogleProviderSettings.tsx
@@ -162,7 +162,7 @@ export default function GoogleProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-google"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-google"
docsTitle="how to sign in users with Google"
icon="/assets/brands/google.svg"
switchId="enabled"
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/LinkedInProviderSettings/LinkedInProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/LinkedInProviderSettings/LinkedInProviderSettings.tsx
index 3d7f4e10d..09d2e1197 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/LinkedInProviderSettings/LinkedInProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/LinkedInProviderSettings/LinkedInProviderSettings.tsx
@@ -142,7 +142,7 @@ export default function LinkedInProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-linkedin"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-linkedin"
docsTitle="how to sign in users with LinkedIn"
icon="/assets/brands/linkedin.svg"
switchId="enabled"
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/SpotifyProviderSettings/SpotifyProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/SpotifyProviderSettings/SpotifyProviderSettings.tsx
index 8cd719b53..984cefea7 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/SpotifyProviderSettings/SpotifyProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/SpotifyProviderSettings/SpotifyProviderSettings.tsx
@@ -142,7 +142,7 @@ export default function SpotifyProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-spotify"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-spotify"
docsTitle="how to sign in users with Spotify"
icon="/assets/brands/spotify.svg"
switchId="enabled"
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/TwitchProviderSettings/TwitchProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/TwitchProviderSettings/TwitchProviderSettings.tsx
index 415da4d93..8b3abc810 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/TwitchProviderSettings/TwitchProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/TwitchProviderSettings/TwitchProviderSettings.tsx
@@ -144,7 +144,7 @@ export default function TwitchProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-twitch"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-twitch"
docsTitle="how to sign in users with Twitch"
icon={
theme.palette.mode === 'dark'
diff --git a/dashboard/src/features/orgs/projects/authentication/settings/components/WorkOsProviderSettings/WorkOsProviderSettings.tsx b/dashboard/src/features/orgs/projects/authentication/settings/components/WorkOsProviderSettings/WorkOsProviderSettings.tsx
index 8d5c5c897..524df709b 100644
--- a/dashboard/src/features/orgs/projects/authentication/settings/components/WorkOsProviderSettings/WorkOsProviderSettings.tsx
+++ b/dashboard/src/features/orgs/projects/authentication/settings/components/WorkOsProviderSettings/WorkOsProviderSettings.tsx
@@ -177,7 +177,7 @@ export default function WorkOsProviderSettings() {
loading: formState.isSubmitting,
},
}}
- docsLink="https://docs.nhost.io/products/auth/social/sign-in-workos"
+ docsLink="https://docs.nhost.io/products/auth/providers/sign-in-workos"
docsTitle="how to sign in users with WorkOS"
icon="/assets/brands/workos.svg"
switchId="enabled"
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ColumnCustomizer.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ColumnCustomizer.tsx
new file mode 100644
index 000000000..154d66590
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ColumnCustomizer.tsx
@@ -0,0 +1,75 @@
+import { DragAndDropList } from '@/components/common/DragAndDropList';
+
+import { isEmptyValue } from '@/lib/utils';
+import type { DropResult } from '@hello-pangea/dnd';
+import type { ColumnInstance } from 'react-table';
+import ColumnCustomizerRow from './ColumnCustomizerRow';
+import ShowHideAllColumnsButtons from './ShowHideAllColumnsButtons';
+
+type ColumnCustomizerProps = {
+ columns: ColumnInstance[];
+ onDragEnd: (columnsOrder: string[]) => void;
+ onReset: () => void;
+ onShowAllColumns: () => void;
+ onHideAllColumns: () => void;
+};
+
+function reorder(list: ColumnInstance[], startIndex: number, endIndex: number) {
+ const result = Array.from(list);
+ const [removed] = result.splice(startIndex, 1);
+ result.splice(endIndex, 0, removed);
+
+ return result;
+}
+
+function ColumnCustomizer({
+ columns,
+ onDragEnd,
+ onReset,
+ onShowAllColumns,
+ onHideAllColumns,
+}: ColumnCustomizerProps) {
+ function handleDragEnd(result: DropResult) {
+ if (isEmptyValue(result.destination)) {
+ return;
+ }
+ const reordered = reorder(
+ columns,
+ result.source.index,
+ result.destination!.index,
+ ).map(({ id }) => id);
+
+ onDragEnd(reordered);
+ }
+
+ return (
+
+
+ Column Settings
+
+ Reorder columns by dragging or show/hide them with checkboxes.
+
+
+
+
+
+
+
+ {columns.map((column, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default ColumnCustomizer;
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ColumnCustomizerRow.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ColumnCustomizerRow.tsx
new file mode 100644
index 000000000..05225d8e6
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ColumnCustomizerRow.tsx
@@ -0,0 +1,49 @@
+import {
+ DraggableItem,
+ type DraggableItemProps,
+} from '@/components/common/DragAndDropList';
+import { Checkbox } from '@/components/ui/v3/checkbox';
+import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath';
+import PersistenDataTableConfigurationStorage from '@/features/orgs/projects/storage/dataGrid/utils/PersistenDataTableConfigurationStorage';
+import { cn } from '@/lib/utils';
+import { GripVertical } from 'lucide-react';
+import type { ColumnInstance } from 'react-table';
+
+type ColumnCustomizerProps = {
+ column: ColumnInstance;
+} & Omit;
+
+function ColumnCustomizerRow({ column, index }: ColumnCustomizerProps) {
+ const tablePath = useTablePath();
+
+ function handleVisibilityChange() {
+ PersistenDataTableConfigurationStorage.toggleColumnVisibility(
+ tablePath,
+ column.id,
+ );
+ column.toggleHidden();
+ }
+
+ return (
+
+
+
+
+ {column.id}
+
+
+
+
+ );
+}
+
+export default ColumnCustomizerRow;
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ShowHideAllColumnsButtons.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ShowHideAllColumnsButtons.tsx
new file mode 100644
index 000000000..b8f405ef2
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/ShowHideAllColumnsButtons.tsx
@@ -0,0 +1,30 @@
+import { Button } from '@/components/ui/v3/button';
+import { ButtonGroup } from '@/components/ui/v3/button-group';
+
+type ShowHideAllColumnsToggleProps = {
+ onShowAll: () => void;
+ onHideAll: () => void;
+ onReset: () => void;
+};
+
+function ShowHideAllColumnsButtons({
+ onShowAll,
+ onHideAll,
+ onReset,
+}: ShowHideAllColumnsToggleProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export default ShowHideAllColumnsButtons;
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/index.ts b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/index.ts
new file mode 100644
index 000000000..7703a24da
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/ColumnCustomizer/index.ts
@@ -0,0 +1 @@
+export { default as ColumnCustomizer } from './ColumnCustomizer';
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerControls.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerControls.tsx
new file mode 100644
index 000000000..e9470913c
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerControls.tsx
@@ -0,0 +1,81 @@
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from '@/components/ui/v3/sheet';
+import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath';
+import { useDataGridConfig } from '@/features/orgs/projects/storage/dataGrid/components/DataGridConfigProvider';
+import PersistenDataTableConfigurationStorage from '@/features/orgs/projects/storage/dataGrid/utils/PersistenDataTableConfigurationStorage';
+import { ColumnCustomizer } from './ColumnCustomizer';
+import { useDataGridCustomizerOpenStateContext } from './DataGridCustomizerOpenStateProvider';
+import DataGridCustomizerTrigger from './DataGridCustomizerTrigger';
+import RowDensityCustomizer from './RowDensityCustomizer';
+
+function DataGridCustomizerControls() {
+ const { allColumns, setColumnOrder, setHiddenColumns } = useDataGridConfig();
+ const tablePath = useTablePath();
+ const { open, setOpen } = useDataGridCustomizerOpenStateContext();
+
+ const columns = allColumns.filter(({ id }) => id !== 'selection-column');
+
+ function saveHiddenCols(cols: string[]) {
+ setHiddenColumns(cols);
+ PersistenDataTableConfigurationStorage.saveHiddenColumns(tablePath, cols);
+ }
+
+ function showOriginalOrder() {
+ setColumnOrder([]);
+ PersistenDataTableConfigurationStorage.saveColumnOrder(tablePath, []);
+ }
+
+ function handleReset() {
+ showOriginalOrder();
+ saveHiddenCols([]);
+ }
+
+ function handleDragEnd(newOrder: string[]) {
+ setColumnOrder(newOrder);
+ PersistenDataTableConfigurationStorage.saveColumnOrder(tablePath, newOrder);
+ }
+
+ function hideAllColumns() {
+ const allColumnsId = columns.map(({ id }) => id);
+ saveHiddenCols(allColumnsId);
+ }
+
+ return (
+
+
+
+
+
+ setOpen(false)}
+ showOverlay
+ >
+
+ Customize Table View
+
+ Customize columns
+
+
+
+
+ saveHiddenCols([])}
+ onHideAllColumns={hideAllColumns}
+ />
+
+
+
+ );
+}
+
+export default DataGridCustomizerControls;
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerOpenStateProvider.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerOpenStateProvider.tsx
new file mode 100644
index 000000000..3c5809221
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerOpenStateProvider.tsx
@@ -0,0 +1,43 @@
+import {
+ createContext,
+ type Dispatch,
+ type PropsWithChildren,
+ type SetStateAction,
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
+
+type DataGridCustomizerOpenStateContextProps = {
+ open: boolean;
+ setOpen: Dispatch>;
+};
+
+const DataGridCustomizerOpenStateContext =
+ createContext({
+ open: false,
+ setOpen: () => {},
+ });
+
+function DataGridCustomizerOpenStateProvider({ children }: PropsWithChildren) {
+ const [open, setOpen] = useState(false);
+ const value = useMemo(
+ () => ({
+ open,
+ setOpen,
+ }),
+ [open],
+ );
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDataGridCustomizerOpenStateContext() {
+ const context = useContext(DataGridCustomizerOpenStateContext);
+ return context;
+}
+
+export default DataGridCustomizerOpenStateProvider;
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerTrigger.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerTrigger.tsx
new file mode 100644
index 000000000..9f5b8ae82
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/DataGridCustomizerTrigger.tsx
@@ -0,0 +1,40 @@
+import { Button, type ButtonProps } from '@/components/ui/v3/button';
+import { useDataGridConfig } from '@/features/orgs/projects/storage/dataGrid/components/DataGridConfigProvider';
+import { cn } from '@/lib/utils';
+import { Columns3 } from 'lucide-react';
+import { type ForwardedRef, forwardRef } from 'react';
+
+function DataBrowserCustomizerTrigger(
+ props: ButtonProps,
+ ref: ForwardedRef,
+) {
+ const { allColumns } = useDataGridConfig();
+ const numberOfHiddenColumns = allColumns.filter(
+ ({ isVisible }) => !isVisible,
+ ).length;
+ const hasHiddenColumns = numberOfHiddenColumns !== 0;
+
+ const { className, ...buttonProps } = props;
+
+ return (
+
+ );
+}
+
+export default forwardRef(DataBrowserCustomizerTrigger);
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/RowDensityCustomizer.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/RowDensityCustomizer.tsx
new file mode 100644
index 000000000..fb969078f
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/RowDensityCustomizer.tsx
@@ -0,0 +1,41 @@
+import { Label } from '@/components/ui/v3/label';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
+import { useDataTableDesignContext } from '@/features/orgs/projects/storage/dataGrid/providers/DataTableDesignProvider';
+
+function RowDensityCustomizer() {
+ const context = useDataTableDesignContext();
+
+ return (
+
+
+ Density
+
+ Set row height across all tables
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default RowDensityCustomizer;
diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/index.ts b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/index.ts
new file mode 100644
index 000000000..edec3e6d1
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/components/DataGridCustomizerControls/index.ts
@@ -0,0 +1 @@
+export { default as DataGridCustomizerControls } from './DataGridCustomizerControls';
diff --git a/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.test.tsx b/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.test.tsx
new file mode 100644
index 000000000..8f9569d5a
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.test.tsx
@@ -0,0 +1,87 @@
+import {
+ getNotFoundProjectStateQuery,
+ getProjectStateQuery,
+} from '@/tests/msw/mocks/graphql/getProjectQuery';
+import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
+import { queryClient, render, screen, waitFor } from '@/tests/testUtils';
+import { ApplicationStatus } from '@/types/application';
+import { setupServer } from 'msw/node';
+import { vi } from 'vitest';
+import useAppState from './useAppState';
+
+function TestComponent() {
+ const { state } = useAppState();
+
+ return State: {state}
;
+}
+
+const mocks = vi.hoisted(() => ({
+ refetch: vi.fn(),
+ useProjectWithState: vi.fn(),
+}));
+
+vi.mock('@/features/orgs/projects/hooks/useProject', async () => ({
+ useProject: () => ({ refetch: mocks.refetch }),
+}));
+
+const server = setupServer(tokenQuery);
+
+describe('useAppState', () => {
+ beforeAll(() => {
+ process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
+ process.env.NEXT_PUBLIC_ENV = 'production';
+ server.listen();
+ });
+
+ beforeEach(() => {
+ server.resetHandlers();
+ });
+
+ afterEach(() => {
+ queryClient.clear();
+ mocks.refetch.mockRestore();
+ mocks.useProjectWithState.mockRestore();
+ vi.restoreAllMocks();
+ });
+
+ it('should refetch the project, when the project is not found', async () => {
+ server.use(getNotFoundProjectStateQuery);
+ render( );
+ expect(await screen.findByText('State: 0')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(mocks.refetch).toHaveBeenCalled();
+ });
+ });
+
+ it('Should not refetch the project if the state is empty', async () => {
+ server.use(getProjectStateQuery([{ stateId: ApplicationStatus.Empty }]));
+ render( );
+ expect(await screen.findByText('State: 0')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(mocks.refetch).not.toHaveBeenCalled();
+ });
+ });
+
+ it('Should return empty state if the application state has not been filled yet', async () => {
+ server.use(getProjectStateQuery([]));
+ render( );
+ expect(await screen.findByText('State: 0')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(mocks.refetch).not.toHaveBeenCalled();
+ });
+ });
+
+ it('Should return the first state from the response', async () => {
+ server.use(
+ getProjectStateQuery([
+ { stateId: ApplicationStatus.Live },
+ { stateId: ApplicationStatus.Empty },
+ ]),
+ );
+ render( );
+ expect(await screen.findByText('State: 5')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(mocks.refetch).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.ts b/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.ts
index d12dc304d..247a8c9a5 100644
--- a/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.ts
+++ b/dashboard/src/features/orgs/projects/common/hooks/useAppState/useAppState.ts
@@ -1,5 +1,8 @@
+import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
+import { isNotEmptyValue } from '@/lib/utils';
import { ApplicationStatus } from '@/types/application';
+import { useEffect } from 'react';
/**
* This hook returns the current application state. If the application state
@@ -7,27 +10,19 @@ import { ApplicationStatus } from '@/types/application';
*/
export default function useAppState(): {
state: ApplicationStatus;
- message?: string | null;
} {
- const { project } = useProjectWithState();
- const noApplication = !project;
+ const { project, projectNotFound } = useProjectWithState();
+ const { refetch } = useProject();
- if (noApplication) {
- return { state: ApplicationStatus.Empty };
- }
-
- const emptyApplicationStates = !project.appStates;
-
- if (noApplication || emptyApplicationStates) {
- return { state: ApplicationStatus.Empty };
- }
-
- if (project.appStates?.length === 0) {
- return { state: ApplicationStatus.Empty };
- }
+ useEffect(() => {
+ if (projectNotFound) {
+ refetch();
+ }
+ }, [projectNotFound, refetch]);
return {
- state: project.appStates[0].stateId,
- message: project.appStates[0].message,
+ state: isNotEmptyValue(project?.appStates?.[0])
+ ? project.appStates[0].stateId
+ : ApplicationStatus.Empty,
};
}
diff --git a/dashboard/src/features/orgs/projects/common/hooks/useCheckProvisioning/useCheckProvisioning.ts b/dashboard/src/features/orgs/projects/common/hooks/useCheckProvisioning/useCheckProvisioning.ts
index 48caf70de..57224765e 100644
--- a/dashboard/src/features/orgs/projects/common/hooks/useCheckProvisioning/useCheckProvisioning.ts
+++ b/dashboard/src/features/orgs/projects/common/hooks/useCheckProvisioning/useCheckProvisioning.ts
@@ -74,9 +74,6 @@ export default function useCheckProvisioning() {
createdAt: data.app.appStates[0].createdAt,
});
stopPolling();
- // Will update the cache and update with the new application state
- // which will trigger the correct application component
- // under `src\components\applications\App.tsx`
memoizedUpdateCache();
return;
}
diff --git a/dashboard/src/features/orgs/projects/common/types/dataTableConfigurationTypes.ts b/dashboard/src/features/orgs/projects/common/types/dataTableConfigurationTypes.ts
new file mode 100644
index 000000000..25387d6e4
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/common/types/dataTableConfigurationTypes.ts
@@ -0,0 +1 @@
+export type RowDensity = 'comfortable' | 'compact';
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseRecordForm/BaseRecordForm.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseRecordForm/BaseRecordForm.tsx
index 40c1b264d..5ca890018 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseRecordForm/BaseRecordForm.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseRecordForm/BaseRecordForm.tsx
@@ -1,12 +1,12 @@
import { useDialog } from '@/components/common/DialogProvider';
import { Form } from '@/components/form/Form';
-import { Box } from '@/components/ui/v2/Box';
-import { Button } from '@/components/ui/v2/Button';
+import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
import { DatabaseRecordInputGroup } from '@/features/orgs/projects/database/dataGrid/components/DatabaseRecordInputGroup';
import type {
ColumnInsertOptions,
DataBrowserGridColumn,
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
+import { cn } from '@/lib/utils';
import type { DialogFormProps } from '@/types/common';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
@@ -153,16 +153,19 @@ export default function BaseRecordForm({
description="These columns are nullable and don't require a value."
columns={optionalColumns}
autoFocusFirstInput={requiredColumns.length === 0}
- sx={{ borderTopWidth: requiredColumns.length > 0 ? 1 : 0 }}
- className="px-6 pt-3"
+ className={cn(
+ 'px-6 pt-3',
+ requiredColumns.length > 0 ? 'border-t-1' : 'border-t-0',
+ )}
/>
)}
-
+
);
}
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseTableForm/ColumnEditorTable.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseTableForm/ColumnEditorTable.tsx
index 5d44829e3..0cc61fa80 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseTableForm/ColumnEditorTable.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/BaseTableForm/ColumnEditorTable.tsx
@@ -25,7 +25,10 @@ export default function ColumnEditorTable() {
return (
<>
-
+
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete/ColumnAutocomplete.stories.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete/ColumnAutocomplete.stories.tsx
deleted file mode 100644
index e4dc6819a..000000000
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete/ColumnAutocomplete.stories.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { Form } from '@/components/form/Form';
-import { Button } from '@/components/ui/v2/Button';
-import { Text } from '@/components/ui/v2/Text';
-import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
-import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
-import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import { FormProvider, useForm } from 'react-hook-form';
-import type { ColumnAutocompleteProps } from './ColumnAutocomplete';
-import ColumnAutocomplete from './ColumnAutocomplete';
-
-export default {
- title: 'Data Browser / ColumnAutocomplete',
- component: ColumnAutocomplete,
- parameters: {
- docs: {
- source: {
- type: 'code',
- },
- },
- },
-} as ComponentMeta;
-
-const defaultParameters = {
- nextRouter: {
- path: '/[workspaceSlug]/[appSlug]/database/browser/[dataSourceSlug]/[schemaSlug]/[tableSlug]',
- asPath: '/workspace/app/database/browser/default/public/users',
- query: {
- workspaceSlug: 'workspace',
- appSlug: 'app',
- dataSourceSlug: 'default',
- schemaSlug: 'public',
- tableSlug: 'books',
- },
- },
- msw: {
- handlers: [tokenQuery, tableQuery, hasuraMetadataQuery],
- },
-};
-
-const Template: ComponentStory = function Template(
- args: ColumnAutocompleteProps,
-) {
- const [submittedValues, setSubmittedValues] = useState('');
-
- const form = useForm<{ firstReference: string; secondReference: string }>({
- defaultValues: {
- firstReference: null as any,
- secondReference: null as any,
- },
- });
-
- function handleSubmit(values: {
- firstReference: string;
- secondReference: string;
- }) {
- setSubmittedValues(JSON.stringify(values, null, 2));
- }
-
- return (
-
-
-
-
-
-
- {submittedValues || 'The form has not been submitted yet.'}
-
-
- );
-};
-
-export const Basic = Template.bind({});
-Basic.args = {
- schema: 'public',
- table: 'books',
-};
-Basic.parameters = defaultParameters;
-
-export const DefaultValue = Template.bind({});
-DefaultValue.args = {
- schema: 'public',
- table: 'books',
- value: 'author.id',
-};
-DefaultValue.parameters = defaultParameters;
-
-export const DisabledRelationships = Template.bind({});
-DisabledRelationships.args = {
- schema: 'public',
- table: 'books',
- disableRelationships: true,
-};
-DisabledRelationships.parameters = defaultParameters;
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/CreateRecordForm/CreateRecordForm.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/CreateRecordForm/CreateRecordForm.tsx
index a49a8ef0c..cb9545027 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/CreateRecordForm/CreateRecordForm.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/CreateRecordForm/CreateRecordForm.tsx
@@ -1,5 +1,6 @@
-import { Alert } from '@/components/ui/v2/Alert';
-import { Button } from '@/components/ui/v2/Button';
+import { Alert } from '@/components/ui/v3/alert';
+import { Button } from '@/components/ui/v3/button';
+import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath';
import type { BaseRecordFormProps } from '@/features/orgs/projects/database/dataGrid/components/BaseRecordForm';
import { BaseRecordForm } from '@/features/orgs/projects/database/dataGrid/components/BaseRecordForm';
import { useCreateRecordMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useCreateRecordMutation';
@@ -7,6 +8,7 @@ import type { ColumnInsertOptions } from '@/features/orgs/projects/database/data
import { createDynamicValidationSchema } from '@/features/orgs/projects/database/dataGrid/utils/validationSchemaHelpers';
import { triggerToast } from '@/utils/toast';
import { yupResolver } from '@hookform/resolvers/yup';
+import { useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateRecordFormProps
@@ -15,14 +17,20 @@ export interface CreateRecordFormProps
* Function to be called when the form is submitted.
*/
onSubmit?: (args?: any) => Promise;
+ currentOffset: number;
+ sortByString: string;
}
export default function CreateRecordForm({
onSubmit,
+ currentOffset,
+ sortByString,
...props
}: CreateRecordFormProps) {
const { mutateAsync: insertRow, error, reset } = useCreateRecordMutation();
const validationSchema = createDynamicValidationSchema(props.columns);
+ const currentTablePath = useTablePath();
+ const queryClient = useQueryClient();
const form = useForm({
defaultValues: props.columns.reduce((defaultValues, column) => {
@@ -42,6 +50,12 @@ export default function CreateRecordForm({
if (onSubmit) {
await onSubmit();
+ await queryClient.invalidateQueries({
+ queryKey: [currentTablePath, currentOffset],
+ });
+ await queryClient.refetchQueries({
+ queryKey: [currentTablePath, currentOffset],
+ });
}
triggerToast('The row has been inserted successfully.');
@@ -55,18 +69,17 @@ export default function CreateRecordForm({
{error && error instanceof Error ? (
Error: {error.message}
-
Clear
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState/DataBrowserEmptyState.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState/DataBrowserEmptyState.tsx
index 417f9fa8a..4ebfd9f8c 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState/DataBrowserEmptyState.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState/DataBrowserEmptyState.tsx
@@ -1,7 +1,6 @@
-import { Text } from '@/components/ui/v2/Text';
+import { cn } from '@/lib/utils';
import Image from 'next/image';
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
-import { twMerge } from 'tailwind-merge';
export interface DataBrowserEmptyStateProps
extends Omit<
@@ -26,7 +25,7 @@ export default function DataBrowserEmptyState({
}: DataBrowserEmptyStateProps) {
return (
-
-
+
{title}
-
-
- {description}
+
+ {description}
);
}
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx
index 2c39d3c48..4f7bfa0ad 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx
@@ -15,17 +15,17 @@ import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGri
import {
POSTGRESQL_CHARACTER_TYPES,
POSTGRESQL_DATE_TIME_TYPES,
- POSTGRESQL_DECIMAL_TYPES,
- POSTGRESQL_INTEGER_TYPES,
POSTGRESQL_JSON_TYPES,
+ POSTGRESQL_NUMERIC_TYPES,
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
import { DataGridDateCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDateCell';
-import { DataGridDecimalCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDecimalCell';
-import { DataGridIntegerCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridIntegerCell';
+import { DataGridNumericCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridNumericCell';
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
+import { isNotEmptyValue } from '@/lib/utils';
+
import { useQueryClient } from '@tanstack/react-query';
import { KeyRound } from 'lucide-react';
import dynamic from 'next/dynamic';
@@ -68,6 +68,7 @@ export function createDataGridColumn(
isEditable,
type: 'text',
specificType: column.full_data_type,
+ dataType: column.data_type,
maxLength: column.character_maximum_length,
Cell: DataGridTextCell,
isPrimary: column.is_primary,
@@ -82,21 +83,13 @@ export function createDataGridColumn(
foreignKeyRelation: column.foreign_key_relation,
};
- if (POSTGRESQL_INTEGER_TYPES.includes(column.data_type)) {
+ if (POSTGRESQL_NUMERIC_TYPES.includes(column.data_type)) {
return {
...defaultColumnConfiguration,
type: 'number',
+ isCopiable: true,
width: 250,
- Cell: DataGridIntegerCell,
- };
- }
-
- if (POSTGRESQL_DECIMAL_TYPES.includes(column.data_type)) {
- return {
- ...defaultColumnConfiguration,
- type: 'text',
- width: 250,
- Cell: DataGridDecimalCell,
+ Cell: DataGridNumericCell,
};
}
@@ -137,6 +130,7 @@ export function createDataGridColumn(
...defaultColumnConfiguration,
type: 'date',
width: 200,
+ isCopiable: true,
Cell: DataGridDateCell,
};
}
@@ -166,8 +160,12 @@ export default function DataBrowserGrid({
const { mutateAsync: updateRow } = useUpdateRecordWithToastMutation();
+ const sortByString = isNotEmptyValue(sortBy?.[0])
+ ? `${sortBy[0].id}.${sortBy[0].desc}`
+ : 'default-order';
+
const { data, status, error, refetch } = useTableQuery(
- [currentTablePath, limit, currentOffset, sortBy],
+ [currentTablePath, currentOffset, sortByString],
{
limit,
offset: currentOffset * limit,
@@ -274,6 +272,8 @@ export default function DataBrowserGrid({
// TODO: Create proper typings for data browser columns
columns={memoizedColumns as unknown as DataBrowserGridColumn[]}
onSubmit={refetch}
+ currentOffset={currentOffset}
+ sortByString={sortByString}
/>
),
});
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx
index c1506a57f..998a3f9f8 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx
@@ -1,21 +1,21 @@
import { useDialog } from '@/components/common/DialogProvider';
-import type { BoxProps } from '@/components/ui/v2/Box';
-import { Box } from '@/components/ui/v2/Box';
-import { Button } from '@/components/ui/v2/Button';
-import { Chip } from '@/components/ui/v2/Chip';
-import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
+import { Badge } from '@/components/ui/v3/badge';
+import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
+import { DataGridCustomizerControls } from '@/features/orgs/projects/common/components/DataGridCustomizerControls';
import { useDeleteRecordMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useDeleteRecordMutation';
import type { DataBrowserGridColumn } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { useDataGridConfig } from '@/features/orgs/projects/storage/dataGrid/components/DataGridConfigProvider';
import type { DataGridPaginationProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGridPagination';
import { DataGridPagination } from '@/features/orgs/projects/storage/dataGrid/components/DataGridPagination';
+import { cn } from '@/lib/utils';
import { triggerToast } from '@/utils/toast';
import { useQueryClient } from '@tanstack/react-query';
+import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { Row } from 'react-table';
import { twMerge } from 'tailwind-merge';
-export interface DataBrowserGridControlsProps extends BoxProps {
+export interface DataBrowserGridControlsProps {
/**
* Props passed to the pagination component.
*/
@@ -33,11 +33,9 @@ export interface DataBrowserGridControlsProps extends BoxProps {
// TODO: Get rid of Data Browser related code from here. This component should
// be generic and not depend on Data Browser related data types and logic.
export default function DataBrowserGridControls({
- className,
paginationProps,
refetchData,
onInsertRowClick,
- ...props
}: DataBrowserGridControlsProps) {
const queryClient = useQueryClient();
const { openAlertDialog } = useDialog();
@@ -98,28 +96,26 @@ export default function DataBrowserGridControls({
}
return (
-
+
0 ? 'justify-between' : 'justify-end',
)}
>
{numberOfSelectedRows > 0 && (
-
+
+ {`${numberOfSelectedRows} selected`}
+
openAlertDialog({
@@ -160,17 +156,13 @@ export default function DataBrowserGridControls({
{...restPaginationProps}
/>
)}
-
- }
- size="small"
- onClick={onInsertRowClick}
- >
- Insert row
+
+
+ Insert row
)}
-
+
);
}
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/DataBrowserSidebar.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/DataBrowserSidebar.tsx
index 68831d37e..75f860e12 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/DataBrowserSidebar.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/DataBrowserSidebar.tsx
@@ -1,42 +1,32 @@
import { useDialog } from '@/components/common/DialogProvider';
-import { NavLink } from '@/components/common/NavLink';
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
-import { InlineCode } from '@/components/presentational/InlineCode';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
-import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Backdrop } from '@/components/ui/v2/Backdrop';
-import type { BoxProps } from '@/components/ui/v2/Box';
-import { Box } from '@/components/ui/v2/Box';
-import { Button } from '@/components/ui/v2/Button';
-import { Chip } from '@/components/ui/v2/Chip';
-import { Divider } from '@/components/ui/v2/Divider';
-import { Dropdown } from '@/components/ui/v2/Dropdown';
-import { IconButton } from '@/components/ui/v2/IconButton';
-import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
-import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
-import { LockIcon } from '@/components/ui/v2/icons/LockIcon';
-import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
-import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
-import { TerminalIcon } from '@/components/ui/v2/icons/TerminalIcon';
-import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
-import { UsersIcon } from '@/components/ui/v2/icons/UsersIcon';
-import { List } from '@/components/ui/v2/List';
-import { ListItem } from '@/components/ui/v2/ListItem';
-import { Option } from '@/components/ui/v2/Option';
-import { Select } from '@/components/ui/v2/Select';
-import { Text } from '@/components/ui/v2/Text';
+import { Badge } from '@/components/ui/v3/badge';
+import { Button } from '@/components/ui/v3/button';
+import { InlineCode } from '@/components/ui/v3/inline-code';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/v3/select';
+import { Spinner } from '@/components/ui/v3/spinner';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { useDatabaseQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useDatabaseQuery';
import { useDeleteTableWithToastMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useDeleteTableMutation';
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
-import { isEmptyValue, isNotEmptyValue } from '@/lib/utils';
+import { cn, isEmptyValue, isNotEmptyValue } from '@/lib/utils';
import { useQueryClient } from '@tanstack/react-query';
+import { Info, Lock, Plus, Terminal } from 'lucide-react';
import dynamic from 'next/dynamic';
import Image from 'next/image';
+import NextLink from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
-import { twMerge } from 'tailwind-merge';
+import TableActions from './TableActions';
const CreateTableForm = dynamic(
() =>
@@ -71,16 +61,17 @@ const EditPermissionsForm = dynamic(
},
);
-export interface DataBrowserSidebarProps extends Omit {
- /**
- * Function to be called when a sidebar item is clicked.
- */
+export interface DataBrowserSidebarProps {
+ className?: string;
+}
+
+export interface DataBrowserSidebarContentProps {
onSidebarItemClick?: (tablePath?: string) => void;
}
function DataBrowserSidebarContent({
onSidebarItemClick,
-}: Pick) {
+}: DataBrowserSidebarContentProps) {
const queryClient = useQueryClient();
const { openDrawer, openAlertDialog } = useDialog();
const { project } = useProject();
@@ -136,11 +127,12 @@ function DataBrowserSidebarContent({
if (status === 'loading') {
return (
-
+
+ Loading schemas and tables...
+
);
}
@@ -252,7 +244,12 @@ function DataBrowserSidebarContent({
Permissions
{table}
-
+
+ Preview
+
),
component: (
@@ -271,59 +268,46 @@ function DataBrowserSidebarContent({
}
return (
-
-
+
+
{schemas && schemas.length > 0 && (
-
-
-
+
-
-
- SQL Editor
-
-
-
-
+
+
+
+ SQL Editor
+
+
+
+
+
);
}
export default function DataBrowserSidebar({
className,
- onSidebarItemClick,
- ...props
}: DataBrowserSidebarProps) {
const isPlatform = useIsPlatform();
const { project } = useProject();
@@ -541,11 +459,7 @@ export default function DataBrowserSidebar({
setExpanded(!expanded);
}
- function handleSidebarItemClick(tablePath?: string) {
- if (onSidebarItemClick && tablePath) {
- onSidebarItemClick(tablePath);
- }
-
+ function handleSidebarItemClick() {
setExpanded(false);
}
@@ -586,24 +500,24 @@ export default function DataBrowserSidebar({
}}
/>
-
-
+
-
@@ -613,7 +527,7 @@ export default function DataBrowserSidebar({
src="/assets/table.svg"
alt="A monochrome table"
/>
-
+
>
);
}
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/TableActions.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/TableActions.tsx
new file mode 100644
index 000000000..e525d3b62
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar/TableActions.tsx
@@ -0,0 +1,111 @@
+import { Button } from '@/components/ui/v3/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/v3/dropdown-menu';
+import { useProject } from '@/features/orgs/projects/hooks/useProject';
+import { cn } from '@/lib/utils';
+import { Ellipsis, SquarePen, Trash2, Users } from 'lucide-react';
+
+const menuItemClassName =
+ 'flex hover:cursor-pointer hover:bg-data-cell-bg h-9 font-medium items-center justify-start gap-2 rounded-none border border-b-1 text-sm+ leading-4';
+
+type Props = {
+ tableName: string;
+ open: boolean;
+ className?: string;
+ onOpen: () => void;
+ onClose: () => void;
+ disabled: boolean;
+ isSelectedNotSchemaLocked: boolean;
+ onDelete: () => void;
+ onEditPermissions: () => void;
+ onViewPermissions: () => void;
+ onEditTable: () => void;
+};
+
+function TableActions({
+ tableName,
+ open,
+ className,
+ onClose,
+ onOpen,
+ disabled,
+ isSelectedNotSchemaLocked,
+ onDelete,
+ onEditPermissions,
+ onViewPermissions,
+ onEditTable,
+}: Props) {
+ const { project } = useProject();
+ const isGitHubConnected = !!project?.githubRepository;
+
+ function handleOnOpenChange(newOpenState: boolean) {
+ if (newOpenState) {
+ onOpen();
+ } else {
+ onClose();
+ }
+ }
+ return (
+
+
+
+
+
+
+
+ {isGitHubConnected ? (
+
+ View Permissions
+
+ ) : (
+ <>
+ {isSelectedNotSchemaLocked && (
+
+ Edit Table
+
+ )}
+
+ Edit Permissions
+
+ {isSelectedNotSchemaLocked && (
+
+
+ Delete Table
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+export default TableActions;
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DatabaseRecordInputGroup/DatabaseRecordInputGroup.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DatabaseRecordInputGroup/DatabaseRecordInputGroup.tsx
index 4780c410b..db1669b42 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DatabaseRecordInputGroup/DatabaseRecordInputGroup.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DatabaseRecordInputGroup/DatabaseRecordInputGroup.tsx
@@ -1,19 +1,18 @@
-import { InlineCode } from '@/components/presentational/InlineCode';
+import { FormInput } from '@/components/form/FormInput';
+import { FormSelect } from '@/components/form/FormSelect';
+import { FormTextarea } from '@/components/form/FormTextarea';
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
-import type { BoxProps } from '@/components/ui/v2/Box';
-import { Box } from '@/components/ui/v2/Box';
-import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
-import { Input } from '@/components/ui/v2/Input';
-import { Option } from '@/components/ui/v2/Option';
-import { Select } from '@/components/ui/v2/Select';
-import { Text } from '@/components/ui/v2/Text';
+import { InlineCode } from '@/components/ui/v3/inline-code';
+import { SelectItem } from '@/components/ui/v3/select';
import type { DataBrowserGridColumn } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { getInputType } from '@/features/orgs/projects/database/dataGrid/utils/inputHelpers';
import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGrid/utils/normalizeDefaultValue';
-import { Controller, useFormContext } from 'react-hook-form';
-import { twMerge } from 'tailwind-merge';
+import { cn } from '@/lib/utils';
+import { KeyRound } from 'lucide-react';
+import { useEffect, useRef } from 'react';
+import { useFormContext } from 'react-hook-form';
-export interface DatabaseRecordInputGroupProps extends BoxProps {
+export interface DatabaseRecordInputGroupProps {
/**
* List of columns for which input fields should be generated.
*/
@@ -30,6 +29,25 @@ export interface DatabaseRecordInputGroupProps extends BoxProps {
* Determines whether the first input field should be focused.
*/
autoFocusFirstInput?: boolean;
+ className?: string;
+}
+
+function getBooleanValueTransformer(isNullable: boolean) {
+ return function transformBooleanValue(value: string | null) {
+ let convertedValue = value;
+
+ if (convertedValue === null) {
+ convertedValue = isNullable ? 'null' : '';
+ } else if (convertedValue === 'null' || convertedValue === '') {
+ convertedValue = null;
+ }
+
+ return convertedValue;
+ };
+}
+
+function convertNullToEmptyString(value: string | null) {
+ return value === null ? '' : value;
}
function getPlaceholder(
@@ -71,28 +89,30 @@ export default function DatabaseRecordInputGroup({
columns,
autoFocusFirstInput,
className,
- ...props
}: DatabaseRecordInputGroupProps) {
- const {
- control,
- register,
- formState: { errors },
- } = useFormContext();
+ const { control } = useFormContext();
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, []);
+
+ function getRef(index: number) {
+ return (element: HTMLTextAreaElement | HTMLInputElement | null) => {
+ if (element && index === 0 && autoFocusFirstInput) {
+ inputRef.current = element;
+ }
+ };
+ }
return (
-
- {title && (
-
- {title}
-
- )}
-
+
+ {title && {title}
}
{description && (
-
- {description}
-
+ {description}
)}
-
{columns.map(
(
@@ -122,99 +142,77 @@ export default function DatabaseRecordInputGroup({
isNullable,
);
- const InputLabel = (
+ const inputLabel = (
-
- {isPrimary && }
-
+
+ {isPrimary && (
+
+ )}
{columnId}
-
+
{specificType}
{maxLength ? `(${maxLength})` : null}
);
- const commonFormControlProps = {
- label: InputLabel,
- error: Boolean(errors[columnId]),
- helperText:
- comment ||
- (typeof errors[columnId]?.message === 'string'
- ? (errors[columnId]?.message as string)
- : null),
- hideEmptyHelperText: true,
- fullWidth: true,
- className: 'py-3',
- };
-
- const commonLabelProps = {
- className: 'grid grid-flow-row justify-items-start gap-1',
- };
-
if (type === 'boolean') {
return (
- (
- field.onChange(value)}
- variant="inline"
- id={columnId}
- value={field.value || 'null'}
- placeholder="Select an option"
- className={twMerge(
- !field.value && 'text-sm font-normal',
- 'py-3',
- )}
- autoFocus={index === 0 && autoFocusFirstInput}
- slotProps={{ label: commonLabelProps }}
- >
-
+ label={inputLabel}
+ placeholder="Select an option"
+ helperText={comment}
+ transformValue={getBooleanValueTransformer(!!isNullable)}
+ >
+
+
+
-
+
+
+
- {isNullable && (
-
- )}
-
+ {isNullable && (
+
+
+
)}
- />
+
);
}
+ const InputComponent = isMultiline ? FormTextarea : FormInput;
return (
-
);
},
)}
-
+
);
}
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/EditPermissionsForm/sections/RowPermissionSection.test.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/EditPermissionsForm/sections/RowPermissionSection.test.tsx
index 76c134fb1..551a2c765 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/EditPermissionsForm/sections/RowPermissionSection.test.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/EditPermissionsForm/sections/RowPermissionSection.test.tsx
@@ -127,6 +127,10 @@ describe('RowPermissionsSection', () => {
process.env.NEXT_PUBLIC_ENV = 'dev';
server.listen();
});
+ beforeEach(() => {
+ server.restoreHandlers();
+ server.resetHandlers();
+ });
afterAll(() => {
server.close();
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupControls.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupControls.tsx
index b001261d6..d634d2079 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupControls.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupControls.tsx
@@ -1,4 +1,3 @@
-import { Text } from '@/components/ui/v2/Text';
import {
Select,
SelectContent,
@@ -70,9 +69,9 @@ export default function RuleGroupControls({
) : (
-
+
{operatorDictionary[currentOperator]}
-
+
)}
);
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupEditor.stories.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupEditor.stories.tsx
deleted file mode 100644
index a77d8963f..000000000
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleGroupEditor.stories.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Form } from '@/components/form/Form';
-import { Button } from '@/components/ui/v2/Button';
-import { Text } from '@/components/ui/v2/Text';
-import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
-import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
-import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
-import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { useState } from 'react';
-import { FormProvider, useForm } from 'react-hook-form';
-import type { RuleGroupEditorProps } from './RuleGroupEditor';
-import RuleGroupEditor from './RuleGroupEditor';
-
-export default {
- title: 'Data Browser / RuleGroupEditor',
- component: RuleGroupEditor,
- parameters: {
- docs: {
- source: {
- type: 'code',
- },
- },
- },
-} as ComponentMeta;
-
-const defaultParameters = {
- nextRouter: {
- path: '/[workspaceSlug]/[appSlug]/database/browser/[dataSourceSlug]/[schemaSlug]/[tableSlug]',
- asPath: '/workspace/app/database/browser/default/public/users',
- query: {
- workspaceSlug: 'workspace',
- appSlug: 'app',
- dataSourceSlug: 'default',
- schemaSlug: 'public',
- tableSlug: 'books',
- },
- },
- msw: {
- handlers: [tableQuery, hasuraMetadataQuery, permissionVariablesQuery],
- },
-};
-
-const Template: ComponentStory = function Template(
- args: RuleGroupEditorProps,
-) {
- const [submittedValues, setSubmittedValues] = useState();
-
- const form = useForm<{ ruleGroupEditor: RuleGroup }>({
- defaultValues: {
- ruleGroupEditor: {
- operator: '_and',
- rules: [{ column: '', operator: '_eq', value: '' }],
- groups: [],
- },
- },
- reValidateMode: 'onSubmit',
- });
-
- function handleSubmit(values: { ruleGroupEditor: RuleGroup }) {
- setSubmittedValues(JSON.stringify(values, null, 2));
- }
-
- // note: Storybook passes `onRemove` as a prop, but we don't want to use it
- return (
-
-
-
-
-
-
- {submittedValues || 'The form has not been submitted yet.'}
-
-
- );
-};
-
-export const Default = Template.bind({});
-Default.args = {};
-Default.parameters = defaultParameters;
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleValueInput.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleValueInput.tsx
index aa631a759..87ca1e4fd 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleValueInput.tsx
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/RuleValueInput.tsx
@@ -147,7 +147,7 @@ function RuleValueInput({
if (operator === '_is_null') {
const defaultValue = comboboxValue ?? undefined;
const triggerClasses =
- 'border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
+ 'border hover:bg-accent-background hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
return (
- res(ctx.json({})),
- ),
+ http.post('http://localhost:1337/v1/metadata', () => HttpResponse.json({})),
);
beforeAll(() => server.listen());
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useRunSQL/useRunSQL.ts b/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useRunSQL/useRunSQL.ts
index 85fa924a1..bf678b6b7 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useRunSQL/useRunSQL.ts
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useRunSQL/useRunSQL.ts
@@ -3,7 +3,7 @@ import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/gen
import { useDatabaseQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useDatabaseQuery';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { getToastStyleProps } from '@/utils/constants/settings';
-import { getHasuraAdminSecret } from '@/utils/env';
+import { getHasuraAdminSecret, getHasuraMigrationsApiUrl } from '@/utils/env';
import { parseIdentifiersFromSQL } from '@/utils/sql';
import { useRouter } from 'next/router';
import { useState } from 'react';
@@ -53,7 +53,10 @@ export default function useRunSQL(
isCascade: boolean,
) => {
try {
- const migrationApiResponse = await fetch(`${appUrl}/apis/migrate`, {
+ const url = isPlatform
+ ? `${appUrl}/apis/migrate`
+ : getHasuraMigrationsApiUrl();
+ const migrationApiResponse = await fetch(url, {
method: 'POST',
headers: { 'x-hasura-admin-secret': adminSecret },
body: JSON.stringify({
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useTrackForeignKeyRelationsMutation/prepareTrackForeignKeyRelationsMetadata.test.ts b/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useTrackForeignKeyRelationsMutation/prepareTrackForeignKeyRelationsMetadata.test.ts
index 2afab24f8..6f9e1bffa 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useTrackForeignKeyRelationsMutation/prepareTrackForeignKeyRelationsMetadata.test.ts
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useTrackForeignKeyRelationsMutation/prepareTrackForeignKeyRelationsMetadata.test.ts
@@ -1,5 +1,5 @@
import type { HasuraMetadata } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
-import { rest } from 'msw';
+import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import prepareTrackForeignKeyRelationsMetadata from './prepareTrackForeignKeyRelationsMetadata';
@@ -28,11 +28,8 @@ const testMetadataResponse: { metadata: HasuraMetadata } = {
};
const metadataHandlers = [
- rest.post(`${APP_URL}/v1/metadata`, (_req, res, ctx) =>
- res(
- ctx.status(200),
- ctx.json<{ metadata: HasuraMetadata }>(testMetadataResponse),
- ),
+ http.post(`${APP_URL}/v1/metadata`, () =>
+ HttpResponse.json(testMetadataResponse),
),
];
@@ -131,56 +128,53 @@ test('should only prepare a one-to-one relationship if the table does not have a
test('should drop existing relationships and prepare a new one-to-many relationship', async () => {
server.use(
- rest.post(`${APP_URL}/v1/metadata`, (_req, res, ctx) =>
- res(
- ctx.status(200),
- ctx.json<{ metadata: HasuraMetadata }>({
- ...testMetadataResponse,
- metadata: {
- ...testMetadataResponse.metadata,
- sources: [
- {
- ...testMetadataResponse.metadata.sources[0],
- tables: [
- {
- ...testMetadataResponse.metadata.sources[0].tables[0],
- object_relationships: [
- {
- name: 'author',
- using: {
- foreign_key_constraint_on: 'author_id',
- },
+ http.post(`${APP_URL}/v1/metadata`, () =>
+ HttpResponse.json({
+ ...testMetadataResponse,
+ metadata: {
+ ...testMetadataResponse.metadata,
+ sources: [
+ {
+ ...testMetadataResponse.metadata.sources[0],
+ tables: [
+ {
+ ...testMetadataResponse.metadata.sources[0].tables[0],
+ object_relationships: [
+ {
+ name: 'author',
+ using: {
+ foreign_key_constraint_on: 'author_id',
},
- ],
- },
- {
- table: {
- name: 'authors',
- schema: 'public',
},
- configuration: {},
- array_relationships: [
- {
- name: 'books',
- using: {
- foreign_key_constraint_on: {
- column: 'author_id',
- table: {
- name: 'books',
- schema: 'public',
- },
+ ],
+ },
+ {
+ table: {
+ name: 'authors',
+ schema: 'public',
+ },
+ configuration: {},
+ array_relationships: [
+ {
+ name: 'books',
+ using: {
+ foreign_key_constraint_on: {
+ column: 'author_id',
+ table: {
+ name: 'books',
+ schema: 'public',
},
},
},
- ],
- object_relationships: [],
- },
- ],
- },
- ],
- },
- }),
- ),
+ },
+ ],
+ object_relationships: [],
+ },
+ ],
+ },
+ ],
+ },
+ }),
),
);
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation/useUpdateRecordWithToastMutation.ts b/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation/useUpdateRecordWithToastMutation.ts
index b7349b5bd..6c635e110 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation/useUpdateRecordWithToastMutation.ts
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation/useUpdateRecordWithToastMutation.ts
@@ -1,4 +1,5 @@
-import { showLoadingToast, triggerToast } from '@/utils/toast';
+import { getToastStyleProps } from '@/utils/constants/settings';
+import { showLoadingToast } from '@/utils/toast';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import type { UseUpdateRecordMutationOptions } from './useUpdateRecordMutation';
@@ -24,6 +25,7 @@ export default function useUpdateRecordWithToastMutation(
if (status === 'loading') {
const loadingToastId = showLoadingToast('Saving data...', {
id: 'data-browser-data-save',
+ ...getToastStyleProps(),
});
setToastId(loadingToastId);
@@ -35,9 +37,13 @@ export default function useUpdateRecordWithToastMutation(
}
if (status === 'success' && toastId) {
- toast.remove(toastId);
-
- triggerToast('Your changes were successfully saved.');
+ setTimeout(() => {
+ toast.remove(toastId);
+ toast.success(
+ 'Your changes were successfully saved.',
+ getToastStyleProps(),
+ );
+ }, 300);
}
}, [status, toastId]);
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/types/dataBrowser/dataBrowser.ts b/dashboard/src/features/orgs/projects/database/dataGrid/types/dataBrowser/dataBrowser.ts
index 8719b0181..bea30f532 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/types/dataBrowser/dataBrowser.ts
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/types/dataBrowser/dataBrowser.ts
@@ -480,6 +480,7 @@ export interface DataBrowserGridColumn
* Determines whether or not the cell content is copiable.
*/
isCopiable?: boolean;
+ dataType?: string;
}
/**
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/utils/postgresqlConstants/postgresqlConstants.ts b/dashboard/src/features/orgs/projects/database/dataGrid/utils/postgresqlConstants/postgresqlConstants.ts
index 94b449ad1..db08ea413 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/utils/postgresqlConstants/postgresqlConstants.ts
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/utils/postgresqlConstants/postgresqlConstants.ts
@@ -19,7 +19,7 @@ export const POSTGRESQL_ERROR_CODES = {
*
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
*/
-export const POSTGRESQL_INTEGER_TYPES = [
+export const POSTGRESQL_NUMERIC_TYPES = [
'smallint',
'integer',
'bigint',
@@ -27,16 +27,21 @@ export const POSTGRESQL_INTEGER_TYPES = [
'serial',
'bigserial',
'oid',
+ 'numeric',
+ 'real',
+ 'double precision',
];
-export const POSTGRESQL_DECIMAL_TYPES = ['numeric', 'real', 'double precision'];
-
/**
* Character data types in PostgreSQL.
*
* @docs https://www.postgresql.org/docs/current/datatype-character.html
*/
-export const POSTGRESQL_CHARACTER_TYPES = ['varchar', 'character', 'text'];
+export const POSTGRESQL_CHARACTER_TYPES = [
+ 'character varying',
+ 'character',
+ 'text',
+];
/**
* JSON data types in PostgreSQL.
diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/utils/validationSchemaHelpers/validationSchemaHelpers.ts b/dashboard/src/features/orgs/projects/database/dataGrid/utils/validationSchemaHelpers/validationSchemaHelpers.ts
index ef37c161b..cd1e81529 100644
--- a/dashboard/src/features/orgs/projects/database/dataGrid/utils/validationSchemaHelpers/validationSchemaHelpers.ts
+++ b/dashboard/src/features/orgs/projects/database/dataGrid/utils/validationSchemaHelpers/validationSchemaHelpers.ts
@@ -108,7 +108,6 @@ export function createDynamicValidationSchema(
[column.id]: createUUIDValidationSchema(details),
};
}
-
if (
column.type === 'date' &&
['time', 'timetz', 'interval'].includes(column.specificType as string)
diff --git a/dashboard/src/features/orgs/projects/database/settings/components/DatabasePiTRSettings/DatabasePiTRSettings.test.tsx b/dashboard/src/features/orgs/projects/database/settings/components/DatabasePiTRSettings/DatabasePiTRSettings.test.tsx
index c30b91e9f..05df7baba 100644
--- a/dashboard/src/features/orgs/projects/database/settings/components/DatabasePiTRSettings/DatabasePiTRSettings.test.tsx
+++ b/dashboard/src/features/orgs/projects/database/settings/components/DatabasePiTRSettings/DatabasePiTRSettings.test.tsx
@@ -3,6 +3,7 @@ import { render, screen, TestUserEvent } from '@/tests/testUtils';
import { vi } from 'vitest';
import DatabasePiTRSettings from './DatabasePiTRSettings';
+import { getOrganizations } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import { setupServer } from 'msw/node';
@@ -75,7 +76,7 @@ vi.mock('@/features/orgs/components/common/TransferProjectDialog', async () => {
};
});
-const server = setupServer(tokenQuery);
+const server = setupServer(tokenQuery, getOrganizations);
describe('DatabasePiTRSettings', () => {
beforeAll(() => {
diff --git a/dashboard/src/features/orgs/projects/database/settings/components/UpgradeNotification/UpgradeNotification.tsx b/dashboard/src/features/orgs/projects/database/settings/components/UpgradeNotification/UpgradeNotification.tsx
index 1560c36dc..c71bb19f8 100644
--- a/dashboard/src/features/orgs/projects/database/settings/components/UpgradeNotification/UpgradeNotification.tsx
+++ b/dashboard/src/features/orgs/projects/database/settings/components/UpgradeNotification/UpgradeNotification.tsx
@@ -52,11 +52,11 @@ function UpgradeNotification({ description }: Props) {
-
+
);
}
diff --git a/dashboard/src/features/orgs/projects/deployments/components/DeploymentServiceLogs/DeploymentServiceLogsHeader.test.tsx b/dashboard/src/features/orgs/projects/deployments/components/DeploymentServiceLogs/DeploymentServiceLogsHeader.test.tsx
index e3848ee49..a2ee38a26 100644
--- a/dashboard/src/features/orgs/projects/deployments/components/DeploymentServiceLogs/DeploymentServiceLogsHeader.test.tsx
+++ b/dashboard/src/features/orgs/projects/deployments/components/DeploymentServiceLogs/DeploymentServiceLogsHeader.test.tsx
@@ -20,6 +20,17 @@ const mockServices = [
'job-backup',
];
+Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
+ value: vi.fn(() => ({
+ width: 100,
+ height: 40,
+ top: 0,
+ left: 0,
+ bottom: 40,
+ right: 100,
+ })),
+});
+
vi.mock('@/features/orgs/projects/hooks/useProject', async () => ({
useProject: () => ({ project: mockApplication }),
}));
diff --git a/dashboard/src/features/orgs/projects/environmentVariables/settings/components/BaseEnvironmentVariableForm/BaseEnvironmentVariableForm.tsx b/dashboard/src/features/orgs/projects/environmentVariables/settings/components/BaseEnvironmentVariableForm/BaseEnvironmentVariableForm.tsx
index bb07c6938..9bb8ebd17 100644
--- a/dashboard/src/features/orgs/projects/environmentVariables/settings/components/BaseEnvironmentVariableForm/BaseEnvironmentVariableForm.tsx
+++ b/dashboard/src/features/orgs/projects/environmentVariables/settings/components/BaseEnvironmentVariableForm/BaseEnvironmentVariableForm.tsx
@@ -50,7 +50,6 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
'TERM',
'NODE_VERSION',
'YARN_VERSION',
- 'NODE_ENV',
'HOME',
].includes(value),
)
diff --git a/dashboard/src/features/orgs/projects/git/common/components/ConnectGitHubModal/ConnectGitHubModal.tsx b/dashboard/src/features/orgs/projects/git/common/components/ConnectGitHubModal/ConnectGitHubModal.tsx
index 04e58f646..a5871a20c 100644
--- a/dashboard/src/features/orgs/projects/git/common/components/ConnectGitHubModal/ConnectGitHubModal.tsx
+++ b/dashboard/src/features/orgs/projects/git/common/components/ConnectGitHubModal/ConnectGitHubModal.tsx
@@ -1,3 +1,4 @@
+import { ErrorMessage } from '@/components/presentational/ErrorMessage';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Avatar } from '@/components/ui/v2/Avatar';
@@ -11,14 +12,33 @@ import { Link } from '@/components/ui/v2/Link';
import { List } from '@/components/ui/v2/List';
import { ListItem } from '@/components/ui/v2/ListItem';
import { Text } from '@/components/ui/v2/Text';
+import { GithubAuthButton } from '@/features/auth/AuthProviders/Github/components/GithubAuthButton';
+import { useHostName } from '@/features/orgs/projects/common/hooks/useHostName';
import { EditRepositorySettings } from '@/features/orgs/projects/git/common/components/EditRepositorySettings';
-import { useGetGithubRepositoriesQuery } from '@/generated/graphql';
+import {
+ getGitHubToken,
+ saveGitHubToken,
+} from '@/features/orgs/projects/git/common/utils';
+import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
+import { useProject } from '@/features/orgs/projects/hooks/useProject';
+import { useGetAuthUserProvidersQuery } from '@/generated/graphql';
+import { useAccessToken } from '@/hooks/useAccessToken';
+import { GitHubAPIError, listGitHubInstallationRepos } from '@/lib/github';
+import { isEmptyValue } from '@/lib/utils';
+import { getToastStyleProps } from '@/utils/constants/settings';
+import { nhost } from '@/utils/nhost';
import { Divider } from '@mui/material';
import debounce from 'lodash.debounce';
+import NavLink from 'next/link';
import type { ChangeEvent } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
+import toast from 'react-hot-toast';
-export type ConnectGitHubModalState = 'CONNECTING' | 'EDITING';
+export type ConnectGitHubModalState =
+ | 'CONNECTING'
+ | 'EDITING'
+ | 'EXPIRED_GITHUB_SESSION'
+ | 'GITHUB_CONNECTION_REQUIRED';
export interface ConnectGitHubModalProps {
/**
@@ -28,18 +48,153 @@ export interface ConnectGitHubModalProps {
close?: VoidFunction;
}
+interface GitHubData {
+ githubAppInstallations: Array<{
+ id: number;
+ accountLogin?: string;
+ accountAvatarUrl?: string;
+ }>;
+ githubRepositories: Array<{
+ id: number;
+ node_id: string;
+ name: string;
+ fullName: string;
+ githubAppInstallation: {
+ accountLogin?: string;
+ accountAvatarUrl?: string;
+ };
+ }>;
+}
+
export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
const [filter, setFilter] = useState('');
const [ConnectGitHubModalState, setConnectGitHubModalState] =
useState('CONNECTING');
const [selectedRepoId, setSelectedRepoId] = useState(null);
+ const [githubData, setGithubData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const { project, loading: loadingProject } = useProject();
+ const { org, loading: loadingOrg } = useCurrentOrg();
+ const hostname = useHostName();
+ const token = useAccessToken();
+ const {
+ data,
+ loading: loadingGithubConnected,
+ error: errorGithubConnected,
+ } = useGetAuthUserProvidersQuery();
- const { data, loading, error, startPolling } =
- useGetGithubRepositoriesQuery();
+ const githubProvider = data?.authUserProviders?.find(
+ (item) => item.providerId === 'github',
+ );
+
+ const getGitHubConnectUrl = () => {
+ if (typeof window !== 'undefined') {
+ return nhost.auth.signInProviderURL('github', {
+ connect: token,
+ redirectTo: `${window.location.origin}?signinProvider=github&state=signin-refresh:${org.slug}:${project?.subdomain}`,
+ });
+ }
+ return '';
+ };
useEffect(() => {
- startPolling(2000);
- }, [startPolling]);
+ if (loadingGithubConnected) {
+ return;
+ }
+
+ const fetchGitHubData = async () => {
+ try {
+ setLoading(true);
+
+ if (isEmptyValue(githubProvider)) {
+ setConnectGitHubModalState('GITHUB_CONNECTION_REQUIRED');
+ setLoading(false);
+ return;
+ }
+ const githubToken = getGitHubToken();
+
+ if (
+ !githubToken?.authUserProviderId ||
+ githubProvider!.id !== githubToken.authUserProviderId
+ ) {
+ setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
+ setLoading(false);
+ return;
+ }
+
+ const { refreshToken, expiresAt: expiresAtString } = githubToken;
+ let accessToken = githubToken?.accessToken;
+
+ const expiresAt = new Date(expiresAtString).getTime();
+
+ const currentTime = Date.now();
+ const expiresAtMargin = 60 * 1000;
+ if (expiresAt - currentTime < expiresAtMargin) {
+ if (!refreshToken) {
+ setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
+ setLoading(false);
+ return;
+ }
+
+ const refreshResponse = await nhost.auth.refreshProviderToken(
+ 'github',
+ { refreshToken },
+ );
+
+ if (!refreshResponse.body) {
+ setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
+ setLoading(false);
+ return;
+ }
+
+ saveGitHubToken({
+ ...refreshResponse.body,
+ authUserProviderId: githubProvider!.id,
+ });
+
+ accessToken = refreshResponse.body.accessToken;
+ }
+
+ const installations = await listGitHubInstallationRepos(accessToken);
+
+ const transformedData = {
+ githubAppInstallations: installations.map((item) => ({
+ id: item.installation.id,
+ accountLogin: item.installation.account?.login,
+ accountAvatarUrl: item.installation.account?.avatar_url,
+ })),
+ githubRepositories: installations.flatMap((item) =>
+ item.repositories.map((repo) => ({
+ id: repo.id,
+ node_id: repo.node_id,
+ name: repo.name,
+ fullName: repo.full_name,
+ githubAppInstallation: {
+ accountLogin: item.installation.account?.login,
+ accountAvatarUrl: item.installation.account?.avatar_url,
+ },
+ })),
+ ),
+ };
+
+ setGithubData(transformedData);
+ setLoading(false);
+ } catch (err) {
+ console.error('Error fetching GitHub data:', err);
+ if (err instanceof GitHubAPIError && err.status === 401) {
+ setConnectGitHubModalState('EXPIRED_GITHUB_SESSION');
+ setLoading(false);
+ return;
+ }
+
+ toast.error(err?.message, getToastStyleProps());
+ close?.();
+ }
+ };
+
+ fetchGitHubData();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [githubProvider, loadingGithubConnected]);
const handleSelectAnotherRepository = () => {
setSelectedRepoId(null);
@@ -56,13 +211,91 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
- if (error) {
- throw error;
+ if (errorGithubConnected instanceof Error) {
+ return (
+
+
+
+
+
+
+
+
+
+ Error fetching GitHub data
+
+ {errorGithubConnected.message}
+
+
+
+ );
}
- if (loading) {
+ if (loading || loadingProject || loadingOrg || loadingGithubConnected) {
return (
-
+
+
+
+
+
+
+
+
+
+ Loading repositories...
+
+
+ Fetching your GitHub repositories
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (ConnectGitHubModalState === 'GITHUB_CONNECTION_REQUIRED') {
+ return (
+
+
+ You need to connect your GitHub account to continue.
+
+
+ }
+ >
+ Connect to GitHub
+
+
+
+ );
+ }
+
+ if (ConnectGitHubModalState === 'EXPIRED_GITHUB_SESSION') {
+ return (
+
+
+ Please sign in with GitHub to continue.
+
+
+
);
}
@@ -78,25 +311,27 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
);
}
- const { githubAppInstallations } = data || {};
+ const { githubAppInstallations } = githubData || {};
- const filteredGitHubAppInstallations = data?.githubAppInstallations.filter(
- (githubApp) => !!githubApp.accountLogin,
- );
+ const filteredGitHubAppInstallations =
+ githubData?.githubAppInstallations.filter(
+ (githubApp) => !!githubApp.accountLogin,
+ );
- const filteredGitHubRepositories = data?.githubRepositories.filter(
+ const filteredGitHubRepositories = githubData?.githubRepositories.filter(
(repo) => !!repo.githubAppInstallation,
);
const filteredGitHubAppInstallationsNullValues =
- data?.githubAppInstallations.filter((githubApp) => !!githubApp.accountLogin)
- .length === 0;
+ githubData?.githubAppInstallations.filter(
+ (githubApp) => !!githubApp.accountLogin,
+ ).length === 0;
const faultyGitHubInstallation =
githubAppInstallations?.length === 0 ||
filteredGitHubAppInstallationsNullValues;
- const noRepositoriesAdded = data?.githubRepositories.length === 0;
+ const noRepositoriesAdded = githubData?.githubRepositories.length === 0;
if (faultyGitHubInstallation) {
return (
@@ -115,11 +350,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
}
>
@@ -179,8 +410,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
- Showing repositories from {data?.githubAppInstallations.length}{' '}
- GitHub account(s)
+ Showing repositories from{' '}
+ {githubData?.githubAppInstallations.length} GitHub account(s)
setSelectedRepoId(repo.id)}
+ onClick={() => setSelectedRepoId(repo.node_id)}
>
Connect
@@ -268,8 +498,7 @@ export default function ConnectGitHubModal({ close }: ConnectGitHubModalProps) {
Do you miss a repository, or do you need to connect another GitHub
account?{' '}
void;
openConnectGithubModal?: () => void;
- selectedRepoId?: string;
+ selectedRepoId: string;
connectGithubModalState?: ConnectGitHubModalState;
handleSelectAnotherRepository?: () => void;
}
diff --git a/dashboard/src/features/orgs/projects/git/common/components/EditRepositorySettingsModal/EditRepositorySettingsModal.tsx b/dashboard/src/features/orgs/projects/git/common/components/EditRepositorySettingsModal/EditRepositorySettingsModal.tsx
index bbe5f35b1..c398f1617 100644
--- a/dashboard/src/features/orgs/projects/git/common/components/EditRepositorySettingsModal/EditRepositorySettingsModal.tsx
+++ b/dashboard/src/features/orgs/projects/git/common/components/EditRepositorySettingsModal/EditRepositorySettingsModal.tsx
@@ -6,14 +6,14 @@ import { Text } from '@/components/ui/v2/Text';
import { EditRepositoryAndBranchSettings } from '@/features/orgs/projects/git/common/components/EditRepositoryAndBranchSettings';
import type { EditRepositorySettingsFormData } from '@/features/orgs/projects/git/common/components/EditRepositorySettings';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
-import { useUpdateApplicationMutation } from '@/generated/graphql';
+import { useConnectGithubRepoMutation } from '@/generated/graphql';
import { analytics } from '@/lib/segment';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { triggerToast } from '@/utils/toast';
import { useFormContext } from 'react-hook-form';
export interface EditRepositorySettingsModalProps {
- selectedRepoId?: string;
+ selectedRepoId: string;
close?: () => void;
handleSelectAnotherRepository?: () => void;
}
@@ -33,45 +33,29 @@ export default function EditRepositorySettingsModal({
const { project, refetch: refetchProject } = useProject();
- const [updateApp, { loading }] = useUpdateApplicationMutation();
+ const [connectGithubRepo, { loading }] = useConnectGithubRepoMutation();
const handleEditGitHubIntegration = async (
data: EditRepositorySettingsFormData,
) => {
try {
- if (!project?.githubRepository || selectedRepoId) {
- await updateApp({
- variables: {
- appId: project?.id,
- app: {
- githubRepositoryId: selectedRepoId,
- repositoryProductionBranch: data.productionBranch,
- nhostBaseFolder: data.repoBaseFolder,
- },
- },
- });
+ await connectGithubRepo({
+ variables: {
+ appID: project?.id,
+ githubNodeID: selectedRepoId,
+ productionBranch: data.productionBranch,
+ baseFolder: data.repoBaseFolder,
+ },
+ });
- if (selectedRepoId) {
- analytics.track('Project Connected to GitHub', {
- projectId: project?.id,
- projectName: project?.name,
- projectSubdomain: project?.subdomain,
- repositoryId: selectedRepoId,
- productionBranch: data.productionBranch,
- baseFolder: data.repoBaseFolder,
- });
- }
- } else {
- await updateApp({
- variables: {
- appId: project.id,
- app: {
- repositoryProductionBranch: data.productionBranch,
- nhostBaseFolder: data.repoBaseFolder,
- },
- },
- });
- }
+ analytics.track('Project Connected to GitHub', {
+ projectId: project?.id,
+ projectName: project?.name,
+ projectSubdomain: project?.subdomain,
+ repositoryId: selectedRepoId,
+ productionBranch: data.productionBranch,
+ baseFolder: data.repoBaseFolder,
+ });
await refetchProject();
diff --git a/dashboard/src/features/orgs/projects/git/common/gql/ConnectGithubRepo.gql b/dashboard/src/features/orgs/projects/git/common/gql/ConnectGithubRepo.gql
new file mode 100644
index 000000000..88747e64f
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/git/common/gql/ConnectGithubRepo.gql
@@ -0,0 +1,13 @@
+mutation ConnectGithubRepo(
+ $appID: uuid!
+ $githubNodeID: String!
+ $productionBranch: String!
+ $baseFolder: String!
+) {
+ connectGithubRepo(
+ appID: $appID
+ githubNodeID: $githubNodeID
+ productionBranch: $productionBranch
+ baseFolder: $baseFolder
+ )
+}
diff --git a/dashboard/src/features/orgs/projects/git/common/hooks/useGitHubModal/useGitHubModal.tsx b/dashboard/src/features/orgs/projects/git/common/hooks/useGitHubModal/useGitHubModal.tsx
index 5f4d784b7..2777eede8 100644
--- a/dashboard/src/features/orgs/projects/git/common/hooks/useGitHubModal/useGitHubModal.tsx
+++ b/dashboard/src/features/orgs/projects/git/common/hooks/useGitHubModal/useGitHubModal.tsx
@@ -2,12 +2,12 @@ import { useDialog } from '@/components/common/DialogProvider';
import { ConnectGitHubModal } from '@/features/orgs/projects/git/common/components/ConnectGitHubModal';
export default function useGitHubModal() {
- const { openAlertDialog } = useDialog();
+ const { openAlertDialog, closeAlertDialog } = useDialog();
function openGitHubModal() {
openAlertDialog({
title: 'Connect GitHub Repository',
- payload: ,
+ payload: ,
props: {
hidePrimaryAction: true,
hideSecondaryAction: true,
diff --git a/dashboard/src/features/orgs/projects/git/common/utils/githubTokens.ts b/dashboard/src/features/orgs/projects/git/common/utils/githubTokens.ts
new file mode 100644
index 000000000..6b100a9f6
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/git/common/utils/githubTokens.ts
@@ -0,0 +1,23 @@
+import { isNotEmptyValue } from '@/lib/utils';
+import type { ProviderSession } from '@nhost/nhost-js/auth';
+
+const githubProviderTokenKey = 'nhost_provider_tokens_github';
+
+export type GitHubProviderToken = ProviderSession & {
+ authUserProviderId?: string;
+};
+
+export function saveGitHubToken(token: GitHubProviderToken) {
+ localStorage.setItem(githubProviderTokenKey, JSON.stringify(token));
+}
+
+export function getGitHubToken() {
+ const token = localStorage.getItem(githubProviderTokenKey);
+ return isNotEmptyValue(token)
+ ? (JSON.parse(token) as GitHubProviderToken)
+ : null;
+}
+
+export function clearGitHubToken() {
+ localStorage.removeItem(githubProviderTokenKey);
+}
diff --git a/dashboard/src/features/orgs/projects/git/common/utils/index.ts b/dashboard/src/features/orgs/projects/git/common/utils/index.ts
new file mode 100644
index 000000000..8aa818004
--- /dev/null
+++ b/dashboard/src/features/orgs/projects/git/common/utils/index.ts
@@ -0,0 +1 @@
+export * from './githubTokens';
diff --git a/dashboard/src/features/orgs/projects/hasura/settings/components/HasuraCorsDomainSettings/HasuraCorsDomainSettings.test.tsx b/dashboard/src/features/orgs/projects/hasura/settings/components/HasuraCorsDomainSettings/HasuraCorsDomainSettings.test.tsx
index 93de145db..ebfd389d9 100644
--- a/dashboard/src/features/orgs/projects/hasura/settings/components/HasuraCorsDomainSettings/HasuraCorsDomainSettings.test.tsx
+++ b/dashboard/src/features/orgs/projects/hasura/settings/components/HasuraCorsDomainSettings/HasuraCorsDomainSettings.test.tsx
@@ -1,15 +1,15 @@
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import { render, screen, waitFor } from '@/tests/testUtils';
-import { graphql } from 'msw';
+import { HttpResponse, graphql } from 'msw';
import { setupServer } from 'msw/node';
import { beforeAll, expect, test, vi } from 'vitest';
import HasuraCorsDomainSettings from './HasuraCorsDomainSettings';
const server = setupServer(
tokenQuery,
- graphql.query('GetHasuraSettings', (_req, res, ctx) =>
- res(
- ctx.data({
+ graphql.query('GetHasuraSettings', () =>
+ HttpResponse.json({
+ data: {
config: {
id: 'HasuraSettings',
__typename: 'HasuraSettings',
@@ -29,8 +29,8 @@ const server = setupServer(
resources: [],
},
},
- }),
- ),
+ },
+ }),
),
);
@@ -62,9 +62,9 @@ describe('HasuraCorsDomainSettings', () => {
test('should enable switch by default when CORS domain is set to one or more domains', async () => {
server.use(
- graphql.query('GetHasuraSettings', (_req, res, ctx) =>
- res(
- ctx.data({
+ graphql.query('GetHasuraSettings', () =>
+ HttpResponse.json({
+ data: {
config: {
id: 'HasuraSettings',
__typename: 'HasuraSettings',
@@ -84,8 +84,8 @@ describe('HasuraCorsDomainSettings', () => {
resources: [],
},
},
- }),
- ),
+ },
+ }),
),
);
diff --git a/dashboard/src/features/orgs/projects/hooks/useProject/useProject.ts b/dashboard/src/features/orgs/projects/hooks/useProject/useProject.ts
index b8dcf0e06..34c8dcc05 100644
--- a/dashboard/src/features/orgs/projects/hooks/useProject/useProject.ts
+++ b/dashboard/src/features/orgs/projects/hooks/useProject/useProject.ts
@@ -1,5 +1,6 @@
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { localApplication } from '@/features/orgs/utils/local-dashboard';
+import { isEmptyValue } from '@/lib/utils';
import { useAuth } from '@/providers/Auth';
import { useNhostClient } from '@/providers/nhost';
import {
@@ -18,6 +19,7 @@ export interface UseProjectReturnType {
loading?: boolean;
error?: Error | null;
refetch: (variables?: any) => Promise;
+ projectNotFound: boolean;
}
export default function useProject(): UseProjectReturnType {
@@ -39,13 +41,14 @@ export default function useProject(): UseProjectReturnType {
[isPlatform, isAuthenticated, isAuthLoading, appSubdomain, isRouterReady],
);
- const { data, isLoading, refetch, error } = useQuery(
+ const { data, isLoading, refetch, error, isFetched } = useQuery(
['project', appSubdomain as string],
async () => {
const response = await nhost.graphql.request<{
apps: ProjectFragment[];
}>(GetProjectDocument, { subdomain: (appSubdomain as string) || '' });
- return response.body;
+
+ return response?.body.data;
},
{
enabled: shouldFetchProject,
@@ -54,10 +57,11 @@ export default function useProject(): UseProjectReturnType {
if (isPlatform) {
return {
- project: data?.data?.apps?.[0] || null,
+ project: data?.apps?.[0] || null,
loading: isLoading && shouldFetchProject,
error: Array.isArray(error || {}) ? error?.[0] : error,
refetch,
+ projectNotFound: isFetched && !isLoading && isEmptyValue(data?.apps),
};
}
@@ -66,5 +70,6 @@ export default function useProject(): UseProjectReturnType {
loading: false,
error: null,
refetch: () => Promise.resolve(),
+ projectNotFound: false,
};
}
diff --git a/dashboard/src/features/orgs/projects/hooks/useProjectLogs/useProjectLogs.test.ts b/dashboard/src/features/orgs/projects/hooks/useProjectLogs/useProjectLogs.test.ts
index 568ee31c2..394dde826 100644
--- a/dashboard/src/features/orgs/projects/hooks/useProjectLogs/useProjectLogs.test.ts
+++ b/dashboard/src/features/orgs/projects/hooks/useProjectLogs/useProjectLogs.test.ts
@@ -82,6 +82,7 @@ describe('useProjectLogs - Subscription Creation & Cleanup', () => {
loading: false,
error: undefined,
refetch: vi.fn(),
+ projectNotFound: false,
});
// Mock subscribeToMore to return an unsubscribe function
@@ -133,6 +134,7 @@ describe('useProjectLogs - Subscription Creation & Cleanup', () => {
loading: true,
error: undefined,
refetch: vi.fn(),
+ projectNotFound: false,
});
renderHook(() => useProjectLogs(defaultProps));
@@ -146,6 +148,7 @@ describe('useProjectLogs - Subscription Creation & Cleanup', () => {
loading: false,
error: undefined,
refetch: vi.fn(),
+ projectNotFound: false,
});
renderHook(() => useProjectLogs(defaultProps));
diff --git a/dashboard/src/features/orgs/projects/logs/components/LogsHeader/LogsHeader.test.tsx b/dashboard/src/features/orgs/projects/logs/components/LogsHeader/LogsHeader.test.tsx
index ea80665fd..87078532f 100644
--- a/dashboard/src/features/orgs/projects/logs/components/LogsHeader/LogsHeader.test.tsx
+++ b/dashboard/src/features/orgs/projects/logs/components/LogsHeader/LogsHeader.test.tsx
@@ -20,6 +20,17 @@ const mockServices = [
'job-backup',
];
+Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
+ value: vi.fn(() => ({
+ width: 100,
+ height: 40,
+ top: 0,
+ left: 0,
+ bottom: 40,
+ right: 100,
+ })),
+});
+
vi.mock('@/features/orgs/projects/hooks/useProject', async () => ({
useProject: () => ({ project: mockApplication }),
}));
diff --git a/dashboard/src/features/orgs/projects/overview/components/OverviewDeployments/OverviewDeployments.test.tsx b/dashboard/src/features/orgs/projects/overview/components/OverviewDeployments/OverviewDeployments.test.tsx
index 1f7ed62c8..aebbf006a 100644
--- a/dashboard/src/features/orgs/projects/overview/components/OverviewDeployments/OverviewDeployments.test.tsx
+++ b/dashboard/src/features/orgs/projects/overview/components/OverviewDeployments/OverviewDeployments.test.tsx
@@ -1,7 +1,7 @@
import { mockApplication, mockOrganization } from '@/tests/mocks';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import { queryClient, render, screen } from '@/tests/testUtils';
-import { rest } from 'msw';
+import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, beforeAll, vi } from 'vitest';
import OverviewDeployments from './OverviewDeployments';
@@ -36,8 +36,9 @@ vi.mock('next/router', () => ({
const server = setupServer(
tokenQuery,
- rest.get('https://local.graphql.local.nhost.run/v1', (_req, res, ctx) =>
- res(ctx.status(200)),
+ http.get(
+ 'https://local.graphql.local.nhost.run/v1',
+ () => new HttpResponse(null, { status: 200 }),
),
);
@@ -49,8 +50,9 @@ beforeAll(() => {
afterEach(() => {
server.resetHandlers(
- rest.get('https://local.graphql.local.nhost.run/v1', (_req, res, ctx) =>
- res(ctx.status(200)),
+ http.get(
+ 'https://local.graphql.local.nhost.run/v1',
+ () => new HttpResponse(null, { status: 200 }),
),
);
queryClient.clear();
@@ -63,37 +65,31 @@ afterAll(() => {
test('should render an empty state when GitHub is not connected', async () => {
server.use(
- rest.post(
+ http.post(
'https://local.graphql.local.nhost.run/v1',
- async (req, res, ctx) => {
- const { operationName } = await req.json();
+ async ({ request }) => {
+ const { operationName } = (await request.json()) as any;
if (operationName === 'getProject') {
- return res(
- ctx.json({
- data: {
- apps: [{ ...mockApplication, githubRepository: null }],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ apps: [{ ...mockApplication, githubRepository: null }],
+ },
+ });
}
if (operationName === 'getOrganization') {
- return res(
- ctx.json({
- data: {
- organizations: [{ ...mockOrganization }],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ organizations: [{ ...mockOrganization }],
+ },
+ });
}
- return res(
- ctx.json({
- data: {
- deployments: [],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ deployments: [],
+ },
+ });
},
),
);
@@ -107,32 +103,28 @@ test('should render an empty state when GitHub is not connected', async () => {
});
test('should render an empty state when GitHub is connected, but there are no deployments', async () => {
server.use(
- rest.post(
+ http.post(
'https://local.graphql.local.nhost.run/v1',
- async (_req, res, ctx) => {
- const { operationName } = await _req.json();
+ async ({ request }) => {
+ const { operationName } = (await request.json()) as any;
if (operationName === 'getProject') {
- return res(
- ctx.json({
- data: {
- apps: [{ ...mockApplication }],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ apps: [{ ...mockApplication }],
+ },
+ });
}
if (operationName === 'getOrganization') {
- return res(
- ctx.json({
- data: {
- organizations: [{ ...mockOrganization }],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ organizations: [{ ...mockOrganization }],
+ },
+ });
}
- return res(ctx.json({ data: { deployments: [] } }));
+ return HttpResponse.json({ data: { deployments: [] } });
},
),
);
@@ -155,52 +147,46 @@ test('should render an empty state when GitHub is connected, but there are no de
test('should render a list of deployments', async () => {
server.use(
tokenQuery,
- rest.post(
+ http.post(
'https://local.graphql.local.nhost.run/v1',
- async (_req, res, ctx) => {
- const { operationName } = await _req.json();
+ async ({ request }) => {
+ const { operationName } = (await request.json()) as any;
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
- return res(ctx.json({ data: { deployments: [] } }));
+ return HttpResponse.json({ data: { deployments: [] } });
}
if (operationName === 'getProject') {
- return res(
- ctx.json({
- data: {
- apps: [{ ...mockApplication }],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ apps: [{ ...mockApplication }],
+ },
+ });
}
if (operationName === 'getOrganization') {
- return res(
- ctx.json({
- data: {
- organizations: [{ ...mockOrganization }],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ organizations: [{ ...mockOrganization }],
+ },
+ });
}
- return res(
- ctx.json({
- data: {
- deployments: [
- {
- id: '1',
- commitSHA: 'abc123',
- deploymentStartedAt: '2021-08-01T00:00:00.000Z',
- deploymentEndedAt: '2021-08-01T00:05:00.000Z',
- deploymentStatus: 'DEPLOYED',
- commitUserName: 'test.user',
- commitUserAvatarUrl: 'http://images.example.com/avatar.png',
- commitMessage: 'Test commit message',
- },
- ],
- },
- }),
- );
+ return HttpResponse.json({
+ data: {
+ deployments: [
+ {
+ id: '1',
+ commitSHA: 'abc123',
+ deploymentStartedAt: '2021-08-01T00:00:00.000Z',
+ deploymentEndedAt: '2021-08-01T00:05:00.000Z',
+ deploymentStatus: 'DEPLOYED',
+ commitUserName: 'test.user',
+ commitUserAvatarUrl: 'http://images.example.com/avatar.png',
+ commitMessage: 'Test commit message',
+ },
+ ],
+ },
+ });
},
),
);
@@ -227,69 +213,61 @@ test('should render a list of deployments', async () => {
test('should disable redeployments if a deployment is already in progress', async () => {
server.use(
tokenQuery,
- rest.post(
+ http.post(
'https://local.graphql.local.nhost.run/v1',
- async (req, res, ctx) => {
- const { operationName } = await req.json();
+ async ({ request }) => {
+ const { operationName } = (await request.json()) as any;
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
- return res(
- ctx.json({
- data: {
- deployments: [
- {
- id: '2',
- commitSHA: 'abc234',
- deploymentStartedAt: '2021-08-02T00:00:00.000Z',
- deploymentEndedAt: null,
- deploymentStatus: 'PENDING',
- commitUserName: 'test.user',
- commitUserAvatarUrl: 'http://images.example.com/avatar.png',
- commitMessage: 'Test commit message',
- },
- ],
- },
- }),
- );
- }
-
- if (operationName === 'getProject') {
- return res(
- ctx.json({
- data: {
- apps: [{ ...mockApplication }],
- },
- }),
- );
- }
- if (operationName === 'getOrganization') {
- return res(
- ctx.json({
- data: {
- organizations: [{ ...mockOrganization }],
- },
- }),
- );
- }
-
- return res(
- ctx.json({
+ return HttpResponse.json({
data: {
deployments: [
{
- id: '1',
- commitSHA: 'abc123',
- deploymentStartedAt: '2021-08-01T00:00:00.000Z',
- deploymentEndedAt: '2021-08-01T00:05:00.000Z',
- deploymentStatus: 'DEPLOYED',
+ id: '2',
+ commitSHA: 'abc234',
+ deploymentStartedAt: '2021-08-02T00:00:00.000Z',
+ deploymentEndedAt: null,
+ deploymentStatus: 'PENDING',
commitUserName: 'test.user',
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
commitMessage: 'Test commit message',
},
],
},
- }),
- );
+ });
+ }
+
+ if (operationName === 'getProject') {
+ return HttpResponse.json({
+ data: {
+ apps: [{ ...mockApplication }],
+ },
+ });
+ }
+ if (operationName === 'getOrganization') {
+ return HttpResponse.json({
+ data: {
+ organizations: [{ ...mockOrganization }],
+ },
+ });
+ }
+
+ return HttpResponse.json({
+ data: {
+ deployments: [
+ {
+ id: '1',
+ commitSHA: 'abc123',
+ deploymentStartedAt: '2021-08-01T00:00:00.000Z',
+ deploymentEndedAt: '2021-08-01T00:05:00.000Z',
+ deploymentStatus: 'DEPLOYED',
+ commitUserName: 'test.user',
+ commitUserAvatarUrl: 'http://images.example.com/avatar.png',
+ commitMessage: 'Test commit message',
+ },
+ ],
+ },
+ });
},
),
);
diff --git a/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/BaseRemoteSchemaForm.tsx b/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/BaseRemoteSchemaForm.tsx
index 02dd49aca..984f15b5e 100644
--- a/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/BaseRemoteSchemaForm.tsx
+++ b/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/BaseRemoteSchemaForm.tsx
@@ -186,6 +186,8 @@ export default function BaseRemoteSchemaForm({
+
+
{graphQLCustomizationsSlot ?? }
diff --git a/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/GraphQLCustomizations.tsx b/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/GraphQLCustomizations.tsx
index 312210db4..45122f554 100644
--- a/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/GraphQLCustomizations.tsx
+++ b/dashboard/src/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/GraphQLCustomizations.tsx
@@ -49,9 +49,9 @@ export default function GraphQLCustomizations() {
size="small"
startIcon={ }
onClick={() => setIsOpen(true)}
- className="mt-2"
+ className="mt-2 px-2"
>
- Add GQL Customization
+ Add GraphQL Customization
);
@@ -95,7 +95,10 @@ export default function GraphQLCustomizations() {
+ typeof v === 'string' && v.trim() === '' ? undefined : v,
+ })}
id="definition.customization.root_fields_namespace"
name="definition.customization.root_fields_namespace"
placeholder="namespace_"
diff --git a/dashboard/src/features/orgs/projects/remote-schemas/components/EditRemoteSchemaForm/sections/EditGraphQLCustomizations.tsx b/dashboard/src/features/orgs/projects/remote-schemas/components/EditRemoteSchemaForm/sections/EditGraphQLCustomizations.tsx
index 30cbb4a2c..0e2ab894b 100644
--- a/dashboard/src/features/orgs/projects/remote-schemas/components/EditRemoteSchemaForm/sections/EditGraphQLCustomizations.tsx
+++ b/dashboard/src/features/orgs/projects/remote-schemas/components/EditRemoteSchemaForm/sections/EditGraphQLCustomizations.tsx
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
+import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { Button as ButtonV3 } from '@/components/ui/v3/button';
@@ -29,7 +30,7 @@ import type {
RemoteSchemaCustomizationFieldNamesItem,
} from '@/utils/hasura-api/generated/schemas';
import { Check, ChevronsUpDown } from 'lucide-react';
-import { useEffect, useMemo } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import {
Controller,
useFieldArray,
@@ -45,6 +46,7 @@ export interface EditGraphQLCustomizationsProps {
export default function EditGraphQLCustomizations({
remoteSchemaName,
}: EditGraphQLCustomizationsProps) {
+ const [isOpen, setIsOpen] = useState(false);
const { data, isLoading, error } =
useIntrospectRemoteSchemaQuery(remoteSchemaName);
@@ -189,12 +191,40 @@ export default function EditGraphQLCustomizations({
);
}
- return (
-
-
+ if (!isOpen) {
+ return (
+
GraphQL Customizations
+ }
+ onClick={() => setIsOpen(true)}
+ className="mt-2 px-2"
+ >
+ Edit GraphQL Customization
+
+
+ );
+ }
+
+ return (
+
+
+
+ GraphQL Customizations
+
+ setIsOpen(false)}
+ >
+ Close
+
@@ -206,7 +236,10 @@ export default function EditGraphQLCustomizations({
+ typeof v === 'string' && v.trim() === '' ? undefined : v,
+ })}
id="definition.customization.root_fields_namespace"
name="definition.customization.root_fields_namespace"
placeholder="namespace_"
@@ -310,10 +343,10 @@ export default function EditGraphQLCustomizations({
)}
/>
-
+
removeTypeRemap(fromType)}
>
@@ -480,10 +513,10 @@ export default function EditGraphQLCustomizations({
fullWidth
/>
-
+
removeFieldName(index)}
>
@@ -492,42 +525,46 @@ export default function EditGraphQLCustomizations({
-
- Field remaps
-
- {fields.map((f) => {
- const key = f.name;
- return (
-
-
- (
- field.onChange(e.target.value)}
- placeholder="new_field_name"
- hideEmptyHelperText
- autoComplete="off"
- variant="inline"
- fullWidth
- />
- )}
- />
-
- );
- })}
+ {fields.length > 0 && (
+
+ Field remaps
+
+ {fields.map((f) => {
+ const key = f.name;
+ return (
+
+
+ (
+
+ field.onChange(e.target.value)
+ }
+ placeholder="new_field_name"
+ hideEmptyHelperText
+ autoComplete="off"
+ variant="inline"
+ fullWidth
+ />
+ )}
+ />
+
+ );
+ })}
+
-
+ )}
);
})}
diff --git a/dashboard/src/features/orgs/projects/remote-schemas/components/RemoteSchemaPreview/RemoteSchemaPreview.tsx b/dashboard/src/features/orgs/projects/remote-schemas/components/RemoteSchemaPreview/RemoteSchemaPreview.tsx
index 7ecca3292..aa662bd5e 100644
--- a/dashboard/src/features/orgs/projects/remote-schemas/components/RemoteSchemaPreview/RemoteSchemaPreview.tsx
+++ b/dashboard/src/features/orgs/projects/remote-schemas/components/RemoteSchemaPreview/RemoteSchemaPreview.tsx
@@ -1,9 +1,11 @@
-import { Input } from '@/components/ui/v2/Input';
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
+import { Input } from '@/components/ui/v3/input';
import useIntrospectRemoteSchemaQuery from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery/useIntrospectRemoteSchemaQuery';
import convertIntrospectionToSchema from '@/features/orgs/projects/remote-schemas/utils/convertIntrospectionToSchema';
-import { Search, X } from 'lucide-react';
-import React, { useMemo, useRef, useState } from 'react';
+import { getToastStyleProps } from '@/utils/constants/settings';
+import { SearchIcon, XIcon } from 'lucide-react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { toast } from 'react-hot-toast';
import type { RemoteSchemaTreeRef } from './RemoteSchemaTree';
import { RemoteSchemaTree } from './RemoteSchemaTree';
@@ -23,6 +25,11 @@ export default function RemoteSchemaPreview({
const [searchTerm, setSearchTerm] = useState('');
const [isSearching, setIsSearching] = useState(false);
+ const [matches, setMatches] = useState([]);
+ const [matchIndex, setMatchIndex] = useState(0);
+ const [hasSearched, setHasSearched] = useState(false);
+ const treeContainerRef = useRef(null);
+ const searchInputRef = useRef(null);
const schema = useMemo(() => {
if (introspectionData) {
@@ -31,6 +38,18 @@ export default function RemoteSchemaPreview({
return null;
}, [introspectionData]);
+ const navigateToMatch = async (paths: string[][], index: number) => {
+ if (!treeRef.current || paths.length === 0) {
+ return;
+ }
+
+ const path = paths[index];
+ await treeRef.current.expandToItem(path);
+ const foundItemId = path[path.length - 1];
+ treeRef.current.selectItems([foundItemId]);
+ treeRef.current.focusItem(foundItemId);
+ };
+
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -41,35 +60,60 @@ export default function RemoteSchemaPreview({
setIsSearching(true);
try {
- const path = treeRef.current.findItemPath(searchTerm);
-
- if (path && path.length > 0) {
- await treeRef.current.expandToItem(path);
-
- const foundItemId = path[path.length - 1];
- treeRef.current.selectItems([foundItemId]);
- treeRef.current.focusItem(foundItemId);
+ const allPaths = treeRef.current.findAllItemPaths(searchTerm);
+ setMatches(allPaths);
+ setHasSearched(true);
+ if (allPaths.length > 0) {
+ setMatchIndex(0);
+ await navigateToMatch(allPaths, 0);
}
} catch (searchError) {
- console.error('Search error:', searchError);
+ toast.error(
+ searchError?.message || 'An error occurred. Please try again.',
+ getToastStyleProps(),
+ );
} finally {
setIsSearching(false);
}
};
- const handleClearSearch = () => {
- setSearchTerm('');
- treeRef.current?.focusTree();
- };
-
const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- handleSearch();
- } else if (e.key === 'Escape') {
- handleClearSearch();
+ if (e.key === 'Enter' && matches.length > 0) {
+ e.preventDefault();
+ const step = e.shiftKey ? -1 : 1;
+ const nextIndex = (matchIndex + step + matches.length) % matches.length;
+ setMatchIndex(nextIndex);
+ navigateToMatch(matches, nextIndex);
}
};
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'Enter') {
+ return;
+ }
+ const { target } = e;
+ const inTree =
+ treeContainerRef.current?.contains(target as Node) ?? false;
+ const isNotSearchInput = target !== searchInputRef.current;
+ if (!inTree || !isNotSearchInput) {
+ return;
+ }
+ if (matches.length === 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ const step = e.shiftKey ? -1 : 1;
+ const nextIndex = (matchIndex + step + matches.length) % matches.length;
+ setMatchIndex(nextIndex);
+ navigateToMatch(matches, nextIndex);
+ };
+ window.addEventListener('keydown', onKeyDown, { capture: true });
+ return () => window.removeEventListener('keydown', onKeyDown, true);
+ }, [matches, matchIndex]);
+
if (isLoading) {
return (
@@ -100,8 +144,8 @@ export default function RemoteSchemaPreview({
}
return (
-
-
+
+
Schema Preview
@@ -112,45 +156,66 @@ export default function RemoteSchemaPreview({