diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php new file mode 100644 index 000000000..575240869 --- /dev/null +++ b/app/Actions/Database/StartRedis.php @@ -0,0 +1,144 @@ +database = $database; + $container_name = $this->database->uuid; + $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + + $this->commands = [ + "echo '####### Starting {$database->name}.'", + "mkdir -p $this->configuration_dir", + ]; + + $persistent_storages = $this->generate_local_persistent_volumes(); + $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); + $environment_variables = $this->generate_environment_variables(); + $docker_compose = [ + 'version' => '3.8', + 'services' => [ + $container_name => [ + 'image' => $this->database->image, + 'command' => "redis-server --requirepass {$this->database->redis_password} --appendonly yes", + 'container_name' => $container_name, + 'environment' => $environment_variables, + 'restart' => RESTART_MODE, + 'networks' => [ + $this->database->destination->network, + ], + 'healthcheck' => [ + 'test' => [ + 'CMD-SHELL', + 'redis-cli', + 'ping' + ], + 'interval' => '5s', + 'timeout' => '5s', + 'retries' => 10, + 'start_period' => '5s' + ], + 'mem_limit' => $this->database->limits_memory, + 'memswap_limit' => $this->database->limits_memory_swap, + 'mem_swappiness' => $this->database->limits_memory_swappiness, + 'mem_reservation' => $this->database->limits_memory_reservation, + 'cpus' => $this->database->limits_cpus, + 'cpuset' => $this->database->limits_cpuset, + 'cpu_shares' => $this->database->limits_cpu_shares, + ] + ], + 'networks' => [ + $this->database->destination->network => [ + 'external' => true, + 'name' => $this->database->destination->network, + 'attachable' => true, + ] + ] + ]; + if (count($this->database->ports_mappings_array) > 0) { + $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; + } + if (count($persistent_storages) > 0) { + $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; + } + if (count($volume_names) > 0) { + $docker_compose['volumes'] = $volume_names; + } + // if (count($this->init_scripts) > 0) { + // foreach ($this->init_scripts as $init_script) { + // $docker_compose['services'][$container_name]['volumes'][] = [ + // 'type' => 'bind', + // 'source' => $init_script, + // 'target' => '/docker-entrypoint-initdb.d/' . basename($init_script), + // 'read_only' => true, + // ]; + // } + // } + $docker_compose = Yaml::dump($docker_compose, 10); + $docker_compose_base64 = base64_encode($docker_compose); + $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d > $this->configuration_dir/docker-compose.yml"; + $readme = generate_readme_file($this->database->name, now()); + $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; + $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; + $this->commands[] = "echo '####### {$database->name} started.'"; + return remote_process($this->commands, $server); + } + + private function generate_local_persistent_volumes() + { + $local_persistent_volumes = []; + foreach ($this->database->persistentStorages as $persistentStorage) { + $volume_name = $persistentStorage->host_path ?? $persistentStorage->name; + $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + } + return $local_persistent_volumes; + } + + private function generate_local_persistent_volumes_only_volume_names() + { + $local_persistent_volumes_names = []; + foreach ($this->database->persistentStorages as $persistentStorage) { + if ($persistentStorage->host_path) { + continue; + } + $name = $persistentStorage->name; + $local_persistent_volumes_names[$name] = [ + 'name' => $name, + 'external' => false, + ]; + } + return $local_persistent_volumes_names; + } + + private function generate_environment_variables() + { + $environment_variables = collect(); + ray('Generate Environment Variables')->green(); + ray($this->database->runtime_environment_variables)->green(); + foreach ($this->database->runtime_environment_variables as $env) { + $environment_variables->push("$env->key=$env->value"); + } + + if ($environment_variables->filter(fn ($env) => Str::of($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + $environment_variables->push("REDIS_PASSWORD={$this->database->redis_password}"); + } + + return $environment_variables->all(); + } +} diff --git a/app/Http/Controllers/DatabaseController.php b/app/Http/Controllers/DatabaseController.php index 958d6d5ab..e8f85110b 100644 --- a/app/Http/Controllers/DatabaseController.php +++ b/app/Http/Controllers/DatabaseController.php @@ -19,7 +19,7 @@ public function configuration() if (!$environment) { return redirect()->route('dashboard'); } - $database = $environment->databases->where('uuid', request()->route('database_uuid'))->first(); + $database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first(); if (!$database) { return redirect()->route('dashboard'); } @@ -37,7 +37,7 @@ public function executions() if (!$environment) { return redirect()->route('dashboard'); } - $database = $environment->databases->where('uuid', request()->route('database_uuid'))->first(); + $database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first(); if (!$database) { return redirect()->route('dashboard'); } @@ -64,7 +64,7 @@ public function backups() if (!$environment) { return redirect()->route('dashboard'); } - $database = $environment->databases->where('uuid', request()->route('database_uuid'))->first(); + $database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first(); if (!$database) { return redirect()->route('dashboard'); } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 7b8cfbfd1..a4db0b1f8 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -59,11 +59,16 @@ public function new() return redirect()->route('dashboard'); } if (in_array($type, DATABASE_TYPES)) { - $standalone_postgresql = create_standalone_postgresql($environment->id, $destination_uuid); + if ($type->value() === "postgresql") { + $database = create_standalone_postgresql($environment->id, $destination_uuid); + } else if ($type->value() === 'redis') { + $database = create_standalone_redis($environment->id, $destination_uuid); + } + ray($database); return redirect()->route('project.database.configuration', [ 'project_uuid' => $project->uuid, 'environment_name' => $environment->name, - 'database_uuid' => $standalone_postgresql->uuid, + 'database_uuid' => $database->uuid, ]); } if ($type->startsWith('one-click-service-') && !is_null( (int)$server_id)) { diff --git a/app/Http/Livewire/Project/Database/Heading.php b/app/Http/Livewire/Project/Database/Heading.php index 3389ac80a..2c739f087 100644 --- a/app/Http/Livewire/Project/Database/Heading.php +++ b/app/Http/Livewire/Project/Database/Heading.php @@ -3,6 +3,7 @@ namespace App\Http\Livewire\Project\Database; use App\Actions\Database\StartPostgresql; +use App\Actions\Database\StartRedis; use App\Jobs\ContainerStatusJob; use Livewire\Component; @@ -26,6 +27,7 @@ public function check_status() { dispatch_sync(new ContainerStatusJob($this->database->destination->server)); $this->database->refresh(); + $this->emit('refresh'); } public function mount() @@ -40,7 +42,7 @@ public function stop() $this->database->destination->server ); if ($this->database->is_public) { - stopPostgresProxy($this->database); + stopDatabaseProxy($this->database); $this->database->is_public = false; } $this->database->status = 'exited'; @@ -55,5 +57,9 @@ public function start() $activity = resolve(StartPostgresql::class)($this->database->destination->server, $this->database); $this->emit('newMonitorActivity', $activity->id); } + if ($this->database->type() === 'standalone-redis') { + $activity = StartRedis::run($this->database->destination->server, $this->database); + $this->emit('newMonitorActivity', $activity->id); + } } } diff --git a/app/Http/Livewire/Project/Database/Postgresql/General.php b/app/Http/Livewire/Project/Database/Postgresql/General.php index 5a03908e1..bbd1de0ff 100644 --- a/app/Http/Livewire/Project/Database/Postgresql/General.php +++ b/app/Http/Livewire/Project/Database/Postgresql/General.php @@ -67,10 +67,10 @@ public function instantSave() } if ($this->database->is_public) { $this->emit('success', 'Starting TCP proxy...'); - startPostgresProxy($this->database); + startDatabaseProxy($this->database); $this->emit('success', 'Database is now publicly accessible.'); } else { - stopPostgresProxy($this->database); + stopDatabaseProxy($this->database); $this->emit('success', 'Database is no longer publicly accessible.'); } $this->getDbUrl(); diff --git a/app/Http/Livewire/Project/Database/Redis/General.php b/app/Http/Livewire/Project/Database/Redis/General.php new file mode 100644 index 000000000..446d720c0 --- /dev/null +++ b/app/Http/Livewire/Project/Database/Redis/General.php @@ -0,0 +1,87 @@ + 'required', + 'database.description' => 'nullable', + 'database.redis_password' => 'required', + 'database.image' => 'required', + 'database.ports_mappings' => 'nullable', + 'database.is_public' => 'nullable|boolean', + 'database.public_port' => 'nullable|integer', + ]; + protected $validationAttributes = [ + 'database.name' => 'Name', + 'database.description' => 'Description', + 'database.redis_password' => 'Postgres User', + 'database.image' => 'Image', + 'database.ports_mappings' => 'Port Mapping', + 'database.is_public' => 'Is Public', + 'database.public_port' => 'Public Port', + ]; + public function submit() { + try { + $this->validate(); + $this->database->save(); + $this->emit('success', 'Database updated successfully.'); + } catch (Exception $e) { + return handleError($e, $this); + } + } + public function instantSave() + { + try { + if ($this->database->is_public && !$this->database->public_port) { + $this->emit('error', 'Public port is required.'); + $this->database->is_public = false; + return; + } + if ($this->database->is_public) { + $this->emit('success', 'Starting TCP proxy...'); + startDatabaseProxy($this->database); + $this->emit('success', 'Database is now publicly accessible.'); + } else { + stopDatabaseProxy($this->database); + $this->emit('success', 'Database is no longer publicly accessible.'); + } + $this->getDbUrl(); + $this->database->save(); + } catch(\Throwable $e) { + $this->database->is_public = !$this->database->is_public; + return handleError($e, $this); + } + } + public function refresh(): void + { + $this->database->refresh(); + } + + public function mount() + { + $this->getDbUrl(); + } + public function getDbUrl() { + + if ($this->database->is_public) { + $this->db_url = "redis://{$this->database->redis_password}@{$this->database->destination->server->getIp}:{$this->database->public_port}/0"; + } else { + $this->db_url = "redis://{$this->database->redis_password}@{$this->database->uuid}:5432/0"; + } + } + public function render() + { + return view('livewire.project.database.redis.general'); + } +} diff --git a/app/Http/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Http/Livewire/Project/Shared/EnvironmentVariable/All.php index a95c94977..ce7c5ae40 100644 --- a/app/Http/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Http/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -75,6 +75,9 @@ public function saveVariables($isPreview) case 'standalone-postgresql': $environment->standalone_postgresql_id = $this->resource->id; break; + case 'standalone-redis': + $environment->standalone_redis_id = $this->resource->id; + break; case 'service': $environment->service_id = $this->resource->id; break; diff --git a/app/Http/Livewire/Project/Shared/Logs.php b/app/Http/Livewire/Project/Shared/Logs.php index 69848a7c5..15a4e510c 100644 --- a/app/Http/Livewire/Project/Shared/Logs.php +++ b/app/Http/Livewire/Project/Shared/Logs.php @@ -6,12 +6,13 @@ use App\Models\Server; use App\Models\Service; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use Livewire\Component; class Logs extends Component { public ?string $type = null; - public Application|StandalonePostgresql|Service $resource; + public Application|StandalonePostgresql|Service|StandaloneRedis $resource; public Server $server; public ?string $container = null; public $parameters; @@ -33,7 +34,14 @@ public function mount() } } else if (data_get($this->parameters, 'database_uuid')) { $this->type = 'database'; - $this->resource = StandalonePostgresql::where('uuid', $this->parameters['database_uuid'])->firstOrFail(); + $resource = StandalonePostgresql::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + $resource = StandaloneRedis::where('uuid', $this->parameters['database_uuid'])->first(); + if (is_null($resource)) { + abort(404); + } + } + $this->resource = $resource; $this->status = $this->resource->status; $this->server = $this->resource->destination->server; $this->container = $this->resource->uuid; diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 624787ba6..f66bf48f2 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -14,7 +14,7 @@ class Environment extends Model public function can_delete_environment() { - return $this->applications()->count() == 0 && $this->postgresqls()->count() == 0 && $this->services()->count() == 0; + return $this->applications()->count() == 0 && $this->redis()->count() == 0 && $this->postgresqls()->count() == 0 && $this->services()->count() == 0; } public function applications() @@ -26,10 +26,16 @@ public function postgresqls() { return $this->hasMany(StandalonePostgresql::class); } + public function redis() + { + return $this->hasMany(StandaloneRedis::class); + } public function databases() { - return $this->postgresqls(); + $postgresqls = $this->postgresqls; + $redis = $this->redis; + return $postgresqls->concat($redis); } public function project() diff --git a/app/Models/Project.php b/app/Models/Project.php index 13f86bf66..f8f9622b8 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -52,4 +52,8 @@ public function postgresqls() { return $this->hasManyThrough(StandalonePostgresql::class, Environment::class); } + public function redis() + { + return $this->hasManyThrough(StandaloneRedis::class, Environment::class); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 313040afa..8b1ebc976 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -123,7 +123,9 @@ public function databases() { return $this->destinations()->map(function ($standaloneDocker) { $postgresqls = $standaloneDocker->postgresqls; - return $postgresqls?->concat([]) ?? collect([]); + $redis = $standaloneDocker->redis; + return $postgresqls->merge($redis); + // return $postgresqls?->concat([]) ?? collect([]); })->flatten(); } public function applications() diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 16d85d956..a594b854a 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -15,6 +15,10 @@ public function postgresqls() { return $this->morphMany(StandalonePostgresql::class, 'destination'); } + public function redis() + { + return $this->morphMany(StandaloneRedis::class, 'destination'); + } public function server() { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php new file mode 100644 index 000000000..f48dc7bb6 --- /dev/null +++ b/app/Models/StandaloneRedis.php @@ -0,0 +1,103 @@ + 'redis-data-' . $database->uuid, + 'mount_path' => '/data', + 'host_path' => null, + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + 'is_readonly' => true + ]); + }); + static::deleting(function ($database) { + // Stop Container + instant_remote_process( + ["docker rm -f {$database->uuid}"], + $database->destination->server, + false + ); + // Stop TCP Proxy + if ($database->is_public) { + instant_remote_process(["docker rm -f {$database->uuid}-proxy"], $database->destination->server, false); + } + $database->scheduledBackups()->delete(); + $database->persistentStorages()->delete(); + $database->environment_variables()->delete(); + // Remove Volume + instant_remote_process(['docker volume rm postgres-data-' . $database->uuid], $database->destination->server, false); + }); + } + + public function portsMappings(): Attribute + { + return Attribute::make( + set: fn ($value) => $value === "" ? null : $value, + ); + } + + // Normal Deployments + + public function portsMappingsArray(): Attribute + { + return Attribute::make( + get: fn () => is_null($this->ports_mappings) + ? [] + : explode(',', $this->ports_mappings), + + ); + } + + public function type(): string + { + return 'standalone-redis'; + } + + public function environment() + { + return $this->belongsTo(Environment::class); + } + + public function fileStorages() + { + return $this->morphMany(LocalFileVolume::class, 'resource'); + } + + public function destination() + { + return $this->morphTo(); + } + + public function environment_variables(): HasMany + { + return $this->hasMany(EnvironmentVariable::class); + } + + public function runtime_environment_variables(): HasMany + { + return $this->hasMany(EnvironmentVariable::class); + } + + public function persistentStorages() + { + return $this->morphMany(LocalPersistentVolume::class, 'resource'); + } + + public function scheduledBackups() + { + return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); + } +} diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index c6fbc3c4d..f915ea041 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -1,6 +1,6 @@ '* * * * *', 'hourly' => '0 * * * *', diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 3b8e893c7..3c4f0dfd9 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -3,6 +3,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use Visus\Cuid2\Cuid2; function generate_database_name(string $type): string @@ -27,6 +28,21 @@ function create_standalone_postgresql($environment_id, $destination_uuid): Stand ]); } +function create_standalone_redis($environment_id, $destination_uuid): StandaloneRedis +{ + $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + if (!$destination) { + throw new Exception('Destination not found'); + } + return StandaloneRedis::create([ + 'name' => generate_database_name('redis'), + 'redis_password' => \Illuminate\Support\Str::password(symbols: false), + 'environment_id' => $environment_id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + /** * Delete file locally on the filesystem. * @param string $filename diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index abada1bda..81cb92c49 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -3,6 +3,7 @@ use App\Actions\Proxy\SaveConfiguration; use App\Models\Server; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use Symfony\Component\Yaml\Yaml; function get_proxy_path() @@ -187,8 +188,14 @@ function setup_default_redirect_404(string|null $redirect_url, Server $server) } } -function startPostgresProxy(StandalonePostgresql $database) +function startDatabaseProxy(StandalonePostgresql|StandaloneRedis $database) { + $internalPort = null; + if ($database->getMorphClass()=== 'App\Models\StandaloneRedis') { + $internalPort = 6379; + } else if ($database->getMorphClass()=== 'App\Models\StandalonePostgresql') { + $internalPort = 5432; + } $containerName = "{$database->uuid}-proxy"; $configuration_dir = database_proxy_dir($database->uuid); $nginxconf = <<public_port; - proxy_pass $database->uuid:5432; + proxy_pass $database->uuid:$internalPort; } } EOF; @@ -260,7 +267,7 @@ function startPostgresProxy(StandalonePostgresql $database) "docker compose --project-directory {$configuration_dir} up --build -d >/dev/null", ], $database->destination->server); } -function stopPostgresProxy(StandalonePostgresql $database) +function stopDatabaseProxy(StandalonePostgresql|StandaloneRedis $database) { instant_remote_process(["docker rm -f {$database->uuid}-proxy"], $database->destination->server); } diff --git a/database/migrations/2023_10_12_132430_create_standalone_redis_table.php b/database/migrations/2023_10_12_132430_create_standalone_redis_table.php new file mode 100644 index 000000000..e6c94dbb6 --- /dev/null +++ b/database/migrations/2023_10_12_132430_create_standalone_redis_table.php @@ -0,0 +1,54 @@ +id(); + $table->string('uuid')->unique(); + $table->string('name'); + $table->string('description')->nullable(); + + $table->text('redis_password'); + $table->longText('redis_conf')->nullable(); + + $table->string('status')->default('exited'); + + $table->string('image')->default('redis:7.2'); + + $table->boolean('is_public')->default(false); + $table->integer('public_port')->nullable(); + $table->text('ports_mappings')->nullable(); + + $table->string('limits_memory')->default("0"); + $table->string('limits_memory_swap')->default("0"); + $table->integer('limits_memory_swappiness')->default(60); + $table->string('limits_memory_reservation')->default("0"); + + $table->string('limits_cpus')->default("0"); + $table->string('limits_cpuset')->nullable()->default("0"); + $table->integer('limits_cpu_shares')->default(1024); + + $table->timestamp('started_at')->nullable(); + $table->morphs('destination'); + $table->foreignId('environment_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('standalone_redis'); + } +}; diff --git a/database/migrations/2023_10_12_132431_add_standalone_redis_to_environment_variables_table.php b/database/migrations/2023_10_12_132431_add_standalone_redis_to_environment_variables_table.php new file mode 100644 index 000000000..2fdacc9e5 --- /dev/null +++ b/database/migrations/2023_10_12_132431_add_standalone_redis_to_environment_variables_table.php @@ -0,0 +1,28 @@ +foreignId('standalone_redis_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('standalone_redis_id'); + }); + } +}; diff --git a/database/seeders/StandaloneRedisSeeder.php b/database/seeders/StandaloneRedisSeeder.php new file mode 100644 index 000000000..cbe10bb00 --- /dev/null +++ b/database/seeders/StandaloneRedisSeeder.php @@ -0,0 +1,23 @@ + 'Local PostgreSQL', + 'description' => 'Local PostgreSQL for testing', + 'redis_password' => 'redis', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + ]); + } +} diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php new file mode 100644 index 000000000..952e48bef --- /dev/null +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -0,0 +1,27 @@ +
+
+
+

General

+ + Save + +
+
+ + + +
+
+

Network

+
+ + + +
+ +
+
+
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 2aa62e83e..5edd410b5 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -94,6 +94,16 @@ +
+
+
+ New Redis +
+
+ The open source, in-memory data store for cache, streaming engine, and message broker. +
+
+
{{--
diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 97a089cf8..0888c1c8e 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -1,7 +1,8 @@
@if ( $resource->getMorphClass() == 'App\Models\Application' || - $resource->getMorphClass() == 'App\Models\StandalonePostgresql') + $resource->getMorphClass() == 'App\Models\StandalonePostgresql' || + $resource->getMorphClass() == 'App\Models\StandaloneRedis')

Storages

@@ -39,6 +37,9 @@ @if ($database->type() === 'standalone-postgresql') @endif + @if ($database->type() === 'standalone-redis') + + @endif
diff --git a/resources/views/project/resources.blade.php b/resources/views/project/resources.blade.php index ac728fc0d..b842f82ee 100644 --- a/resources/views/project/resources.blade.php +++ b/resources/views/project/resources.blade.php @@ -46,7 +46,7 @@ class="items-center justify-center box">+ Add New Resource
@endforeach - @foreach ($environment->databases->sortBy('name') as $databases) + @foreach ($environment->databases()->sortBy('name') as $databases)