diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index f7464a697..1a411cf59 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -45,6 +45,7 @@ class DeleteService foreach ($service->databases()->get() as $database) { $database->forceDelete(); } + $service->tags()->detach(); } } } diff --git a/app/Http/Controllers/Api/Deploy.php b/app/Http/Controllers/Api/Deploy.php new file mode 100644 index 000000000..322d73e67 --- /dev/null +++ b/app/Http/Controllers/Api/Deploy.php @@ -0,0 +1,154 @@ +user()->currentAccessToken(); + $teamId = data_get($token, 'team_id'); + $uuids = $request->query->get('uuid'); + $tags = $request->query->get('tag'); + $force = $request->query->get('force') ?? false; + + if ($uuids && $tags) { + return response()->json(['error' => 'You can only use uuid or tag, not both.', 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 400); + } + if (is_null($teamId)) { + return response()->json(['error' => 'Invalid token.', 'docs' => 'https://coolify.io/docs/api/authentication'], 400); + } + if ($tags) { + return $this->by_tags($tags, $teamId, $force); + } else if ($uuids) { + return $this->by_uuids($uuids, $teamId, $force); + } + return response()->json(['error' => 'You must provide uuid or tag.', 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 400); + } + private function by_uuids(string $uuid, int $teamId, bool $force = false) + { + $uuids = explode(',', $uuid); + $uuids = collect(array_filter($uuids)); + + if (count($uuids) === 0) { + return response()->json(['error' => 'No UUIDs provided.', 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 400); + } + $message = collect([]); + foreach ($uuids as $uuid) { + $resource = getResourceByUuid($uuid, $teamId); + if ($resource) { + $return_message = $this->deploy_resource($resource, $force); + $message = $message->merge($return_message); + } + } + if ($message->count() > 0) { + return response()->json(['message' => $message->toArray()], 200); + } + return response()->json(['error' => "No resources found.", 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 404); + } + public function by_tags(string $tags, int $team_id, bool $force = false) + { + $tags = explode(',', $tags); + $tags = collect(array_filter($tags)); + + if (count($tags) === 0) { + return response()->json(['error' => 'No TAGs provided.', 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 400); + } + $message = collect([]); + foreach ($tags as $tag) { + $found_tag = Tag::where(['name' => $tag, 'team_id' => $team_id])->first(); + if (!$found_tag) { + $message->push("Tag {$tag} not found."); + continue; + } + $resources = $found_tag->resources()->get(); + if ($resources->count() === 0) { + $message->push("No resources found for tag {$tag}."); + continue; + } + foreach ($resources as $resource) { + $return_message = $this->deploy_resource($resource, $force); + $message = $message->merge($return_message); + } + } + if ($message->count() > 0) { + return response()->json(['message' => $message->toArray()], 200); + } + + return response()->json(['error' => "No resources found.", 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 404); + } + public function deploy_resource($resource, bool $force = false): Collection + { + $message = collect([]); + $type = $resource->getMorphClass(); + if ($type === 'App\Models\Application') { + queue_application_deployment( + application: $resource, + deployment_uuid: new Cuid2(7), + force_rebuild: $force, + ); + $message->push("Application {$resource->name} deployment queued."); + } else if ($type === 'App\Models\StandalonePostgresql') { + if (str($resource->status)->startsWith('running')) { + $message->push("Database {$resource->name} already running."); + } + StartPostgresql::run($resource); + $resource->update([ + 'started_at' => now(), + ]); + $message->push("Database {$resource->name} started."); + } else if ($type === 'App\Models\StandaloneRedis') { + if (str($resource->status)->startsWith('running')) { + $message->push("Database {$resource->name} already running."); + } + StartRedis::run($resource); + $resource->update([ + 'started_at' => now(), + ]); + $message->push("Database {$resource->name} started."); + } else if ($type === 'App\Models\StandaloneMongodb') { + if (str($resource->status)->startsWith('running')) { + $message->push("Database {$resource->name} already running."); + } + StartMongodb::run($resource); + $resource->update([ + 'started_at' => now(), + ]); + $message->push("Database {$resource->name} started."); + } else if ($type === 'App\Models\StandaloneMysql') { + if (str($resource->status)->startsWith('running')) { + $message->push("Database {$resource->name} already running."); + } + StartMysql::run($resource); + $resource->update([ + 'started_at' => now(), + ]); + $message->push("Database {$resource->name} started."); + } else if ($type === 'App\Models\StandaloneMariadb') { + if (str($resource->status)->startsWith('running')) { + $message->push("Database {$resource->name} already running."); + } + StartMariadb::run($resource); + $resource->update([ + 'started_at' => now(), + ]); + $message->push("Database {$resource->name} started."); + } else if ($type === 'App\Models\Service') { + StartService::run($resource); + $message->push("Service {$resource->name} started. It could take a while, be patient."); + } + return $message; + } +} diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 718b5003c..5f1753c9e 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -29,7 +29,7 @@ class Index extends Component } $this->project = $project; $this->environment = $environment; - $this->applications = $environment->applications->sortBy('name'); + $this->applications = $environment->applications->load(['tags']); $this->applications = $this->applications->map(function ($application) { if (data_get($application, 'environment.project.uuid')) { $application->hrefLink = route('project.application.configuration', [ @@ -40,8 +40,9 @@ class Index extends Component } return $application; }); - $this->postgresqls = $environment->postgresqls->sortBy('name'); - $this->postgresqls = $this->postgresqls->map(function ($postgresql) { + ray($this->applications); + $this->postgresqls = $environment->postgresqls->load(['tags'])->sortBy('name'); + $this->postgresqls = $this->postgresqls->map(function ($postgresql) { if (data_get($postgresql, 'environment.project.uuid')) { $postgresql->hrefLink = route('project.database.configuration', [ 'project_uuid' => data_get($postgresql, 'environment.project.uuid'), @@ -51,7 +52,7 @@ class Index extends Component } return $postgresql; }); - $this->redis = $environment->redis->sortBy('name'); + $this->redis = $environment->redis->load(['tags'])->sortBy('name'); $this->redis = $this->redis->map(function ($redis) { if (data_get($redis, 'environment.project.uuid')) { $redis->hrefLink = route('project.database.configuration', [ @@ -62,7 +63,7 @@ class Index extends Component } return $redis; }); - $this->mongodbs = $environment->mongodbs->sortBy('name'); + $this->mongodbs = $environment->mongodbs->load(['tags'])->sortBy('name'); $this->mongodbs = $this->mongodbs->map(function ($mongodb) { if (data_get($mongodb, 'environment.project.uuid')) { $mongodb->hrefLink = route('project.database.configuration', [ @@ -73,7 +74,7 @@ class Index extends Component } return $mongodb; }); - $this->mysqls = $environment->mysqls->sortBy('name'); + $this->mysqls = $environment->mysqls->load(['tags'])->sortBy('name'); $this->mysqls = $this->mysqls->map(function ($mysql) { if (data_get($mysql, 'environment.project.uuid')) { $mysql->hrefLink = route('project.database.configuration', [ @@ -84,7 +85,7 @@ class Index extends Component } return $mysql; }); - $this->mariadbs = $environment->mariadbs->sortBy('name'); + $this->mariadbs = $environment->mariadbs->load(['tags'])->sortBy('name'); $this->mariadbs = $this->mariadbs->map(function ($mariadb) { if (data_get($mariadb, 'environment.project.uuid')) { $mariadb->hrefLink = route('project.database.configuration', [ @@ -95,7 +96,7 @@ class Index extends Component } return $mariadb; }); - $this->services = $environment->services->sortBy('name'); + $this->services = $environment->services->load(['tags'])->sortBy('name'); $this->services = $this->services->map(function ($service) { if (data_get($service, 'environment.project.uuid')) { $service->hrefLink = route('project.service.configuration', [ diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php new file mode 100644 index 000000000..db330b15c --- /dev/null +++ b/app/Livewire/Project/Shared/Tags.php @@ -0,0 +1,88 @@ + '$refresh', + ]; + protected $rules = [ + 'resource.tags.*.name' => 'required|string|min:2', + 'new_tag' => 'required|string|min:2' + ]; + protected $validationAttributes = [ + 'new_tag' => 'tag' + ]; + public function mount() + { + $this->tags = Tag::ownedByCurrentTeam()->get(); + } + public function addTag(string $id, string $name) + { + try { + if ($this->resource->tags()->where('id', $id)->exists()) { + $this->dispatch('error', 'Duplicate tags.', "Tag $name already added."); + return; + } + $this->resource->tags()->syncWithoutDetaching($id); + $this->refresh(); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function deleteTag($id, $name) + { + try { + $found_more_tags = Tag::where(['name' => $name, 'team_id' => currentTeam()->id])->first(); + $this->resource->tags()->detach($id); + if ($found_more_tags->resources()->get()->count() == 0) { + $found_more_tags->delete(); + } + $this->refresh(); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function refresh() + { + $this->resource->load(['tags']); + $this->new_tag = null; + } + public function submit() + { + try { + $this->validate([ + 'new_tag' => 'required|string|min:2' + ]); + $tags = str($this->new_tag)->trim()->explode(' '); + foreach ($tags as $tag) { + if ($this->resource->tags()->where('name', $tag)->exists()) { + $this->dispatch('error', 'Duplicate tags.', "Tag $tag already added."); + continue; + } + $found = Tag::where(['name' => $tag, 'team_id' => currentTeam()->id])->first(); + if (!$found) { + $found = Tag::create([ + 'name' => $tag, + 'team_id' => currentTeam()->id + ]); + } + $this->resource->tags()->syncWithoutDetaching($found->id); + } + $this->refresh(); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function render() + { + return view('livewire.project.shared.tags'); + } +} diff --git a/app/Livewire/Tags/Index.php b/app/Livewire/Tags/Index.php new file mode 100644 index 000000000..eba25a750 --- /dev/null +++ b/app/Livewire/Tags/Index.php @@ -0,0 +1,18 @@ +tags = Tag::where('team_id', currentTeam()->id)->get()->unique('name')->sortBy('name'); + } + public function render() + { + return view('livewire.tags.index'); + } +} diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php new file mode 100644 index 000000000..6a61a0851 --- /dev/null +++ b/app/Livewire/Tags/Show.php @@ -0,0 +1,62 @@ +resources->pluck('id'); + $this->deployments_per_tag_per_server = ApplicationDeploymentQueue::whereIn("status", ["in_progress", "queued"])->whereIn('application_id', $resource_ids)->get([ + "id", + "application_id", + "application_name", + "deployment_url", + "pull_request_id", + "server_name", + "server_id", + "status" + ])->sortBy('id')->groupBy('server_name')->toArray(); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function redeploy_all() + { + try { + $this->resources->each(function ($resource) { + $deploy = new Deploy(); + $deploy->deploy_resource($resource); + }); + $this->dispatch('success', 'Mass deployment started.'); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function mount() + { + $tag = Tag::ownedByCurrentTeam()->where('name', request()->tag_name)->first(); + if (!$tag) { + return redirect()->route('tags.index'); + } + $this->webhook = generatTagDeployWebhook($tag->name); + $this->resources = $tag->resources()->get(); + $this->tag = $tag; + $this->get_deployments(); + } + public function render() + { + return view('livewire.tags.show'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 1f9614baa..b91acdcff 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -49,6 +49,7 @@ class Application extends BaseModel $application->persistentStorages()->delete(); $application->environment_variables()->delete(); $application->environment_variables_preview()->delete(); + $application->tags()->detach(); }); } @@ -211,6 +212,10 @@ class Application extends BaseModel : explode(',', $this->ports_exposes) ); } + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function team() { return data_get($this, 'environment.project.team'); diff --git a/app/Models/Service.php b/app/Models/Service.php index adbf6c500..b6d5e86d3 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -20,6 +20,10 @@ class Service extends BaseModel { return data_get($this, 'environment.project.team'); } + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function extraFields() { $fields = collect([]); diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 8b3f3412d..4d56f11a3 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -40,8 +40,14 @@ class StandaloneMariadb extends BaseModel $database->scheduledBackups()->delete(); $database->persistentStorages()->delete(); $database->environment_variables()->delete(); + $database->tags()->detach(); }); } + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function team() { return data_get($this, 'environment.project.team'); diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index de2e7f2eb..939af0974 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -43,8 +43,14 @@ class StandaloneMongodb extends BaseModel $database->scheduledBackups()->delete(); $database->persistentStorages()->delete(); $database->environment_variables()->delete(); + $database->tags()->detach(); }); } + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function team() { return data_get($this, 'environment.project.team'); diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index c9608f1cc..8bcc0d9fe 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -40,8 +40,14 @@ class StandaloneMysql extends BaseModel $database->scheduledBackups()->delete(); $database->persistentStorages()->delete(); $database->environment_variables()->delete(); + $database->tags()->detach(); }); } + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function team() { return data_get($this, 'environment.project.team'); diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 577904260..fb6ad944d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -40,8 +40,14 @@ class StandalonePostgresql extends BaseModel $database->scheduledBackups()->delete(); $database->persistentStorages()->delete(); $database->environment_variables()->delete(); + $database->tags()->detach(); }); } + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function link() { if (data_get($this, 'environment.project.uuid')) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 5f4279183..73fa61a6c 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -35,8 +35,14 @@ class StandaloneRedis extends BaseModel } $database->persistentStorages()->delete(); $database->environment_variables()->delete(); + $database->tags()->detach(); }); } + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } public function team() { return data_get($this, 'environment.project.team'); diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 000000000..02b3a7ff5 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,32 @@ + strtolower($value), + set: fn ($value) => strtolower($value) + ); + } + static public function ownedByCurrentTeam() + { + return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); + } + public function applications() + { + return $this->morphedByMany(Application::class, 'taggable'); + } + + public function resources() { + return $this->applications(); + } + +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2a6ce9647..ad64b2550 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -110,9 +110,9 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n } if ($error instanceof UniqueConstraintViolationException) { if (isset($livewire)) { - return $livewire->dispatch('error', "A resource with the same name already exists."); + return $livewire->dispatch('error', "Duplicate entry found.", "Please use a different name."); } - return "A resource with the same name already exists."; + return "Duplicate entry found. Please use a different name."; } if ($error instanceof Throwable) { @@ -481,7 +481,14 @@ function queryResourcesByUuid(string $uuid) if ($mariadb) return $mariadb; return $resource; } - +function generatTagDeployWebhook($tag_name) +{ + $baseUrl = base_url(); + $api = Url::fromString($baseUrl) . '/api/v1'; + $endpoint = "/deploy?tag=$tag_name"; + $url = $api . $endpoint; + return $url; +} function generateDeployWebhook($resource) { $baseUrl = base_url(); diff --git a/config/sentry.php b/config/sentry.php index cb35fa610..699995883 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.204', + 'release' => '4.0.0-beta.205', // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/toaster.php b/config/toaster.php deleted file mode 100644 index 43565a9c6..000000000 --- a/config/toaster.php +++ /dev/null @@ -1,48 +0,0 @@ - true, - - /** - * The vertical alignment of the toast container. - * - * Supported: "bottom", "middle" or "top" - */ - 'alignment' => 'top', - - /** - * Allow users to close toast messages prematurely. - * - * Supported: true | false - */ - 'closeable' => true, - - /** - * The on-screen duration of each toast. - * - * Minimum: 3000 (in milliseconds) - */ - 'duration' => 5000, - - /** - * The horizontal position of each toast. - * - * Supported: "center", "left" or "right" - */ - 'position' => 'center', - - /** - * Whether messages passed as translation keys should be translated automatically. - * - * Supported: true | false - */ - 'translate' => true, -]; diff --git a/config/version.php b/config/version.php index 0cbf5f9b7..ff50755a2 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ id(); + $table->string('uuid')->unique(); + $table->string('name')->unique(); + $table->foreignId('team_id')->nullable()->constrained()->onDelete('cascade'); + $table->timestamps(); + }); + Schema::create('taggables', function (Blueprint $table) { + $table->unsignedBigInteger('tag_id'); + $table->unsignedBigInteger('taggable_id'); + $table->string('taggable_type'); + $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade'); + $table->unique(['tag_id', 'taggable_id', 'taggable_type'], 'taggable_unique'); // Composite unique index + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('taggables'); + Schema::dropIfExists('tags'); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 38a452d85..dbde2a2ad 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -14,6 +14,10 @@ button[isError] { @apply bg-red-600 hover:bg-red-700; } +button[isHighlighted] { + @apply bg-coollabs hover:bg-coollabs-100; +} + .scrollbar { @apply scrollbar-thumb-coollabs-100 scrollbar-track-coolgray-200 scrollbar-w-2; } @@ -76,7 +80,7 @@ a { } .box-without-bg { - @apply flex p-2 transition-colors min-h-full hover:text-white hover:no-underline min-h-[4rem]; + @apply flex p-2 transition-colors hover:text-white hover:no-underline min-h-[4rem]; } .description { diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index cad924e7b..9256dc502 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -4,7 +4,7 @@ class="transition rounded w-11 h-11" src="{{ asset('coolify-transparent.png') }}">