feat: tags and tag deploy webhooks

This commit is contained in:
Andras Bacsai 2024-02-01 15:38:12 +01:00
parent 44efe0b5e1
commit 6312c0ba84
20 changed files with 394 additions and 98 deletions

View File

@ -29,7 +29,7 @@ public function mount()
} }
$this->project = $project; $this->project = $project;
$this->environment = $environment; $this->environment = $environment;
$this->applications = $environment->applications->sortBy('name'); $this->applications = $environment->applications->load(['tags'])->sortBy('name');
$this->applications = $this->applications->map(function ($application) { $this->applications = $this->applications->map(function ($application) {
if (data_get($application, 'environment.project.uuid')) { if (data_get($application, 'environment.project.uuid')) {
$application->hrefLink = route('project.application.configuration', [ $application->hrefLink = route('project.application.configuration', [

View File

@ -0,0 +1,62 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\Tag;
use Livewire\Component;
class Tags extends Component
{
public $resource = null;
public ?string $new_tag = null;
protected $listeners = [
'refresh' => '$refresh',
];
public function mount()
{
}
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) {
$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');
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Livewire\Tags;
use App\Models\Tag;
use Livewire\Component;
class Index extends Component
{
public $tags = [];
public function mount() {
$this->tags = Tag::where('team_id', currentTeam()->id)->get()->unique('name')->sortBy('name');
}
public function render()
{
return view('livewire.tags.index');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Livewire\Tags;
use App\Models\Tag;
use Livewire\Component;
class Show extends Component
{
public Tag $tag;
public $resources;
public $webhook = null;
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;
}
public function render()
{
return view('livewire.tags.show');
}
}

View File

@ -211,6 +211,10 @@ public function portsExposesArray(): Attribute
: explode(',', $this->ports_exposes) : explode(',', $this->ports_exposes)
); );
} }
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function team() public function team()
{ {
return data_get($this, 'environment.project.team'); return data_get($this, 'environment.project.team');

32
app/Models/Tag.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Tag extends BaseModel
{
protected $guarded = [];
public function name(): Attribute
{
return Attribute::make(
get: fn ($value) => 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();
}
}

View File

@ -110,9 +110,9 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
} }
if ($error instanceof UniqueConstraintViolationException) { if ($error instanceof UniqueConstraintViolationException) {
if (isset($livewire)) { 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) { if ($error instanceof Throwable) {
@ -481,7 +481,14 @@ function queryResourcesByUuid(string $uuid)
if ($mariadb) return $mariadb; if ($mariadb) return $mariadb;
return $resource; return $resource;
} }
function generatTagDeployWebhook($tag_name)
{
$baseUrl = base_url();
$api = Url::fromString($baseUrl) . '/api/v1';
$endpoint = "/deploy/tag/$tag_name";
$url = $api . $endpoint . "?force=false";
return $url;
}
function generateDeployWebhook($resource) function generateDeployWebhook($resource)
{ {
$baseUrl = base_url(); $baseUrl = base_url();

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
return [
/**
* Add an additional second for every 100th word of the toast messages.
*
* Supported: true | false
*/
'accessibility' => 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,
];

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->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');
}
};

View File

@ -76,7 +76,7 @@ .box {
} }
.box-without-bg { .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 { .description {

View File

@ -83,6 +83,18 @@ class="{{ request()->is('command-center') ? 'text-warning icon' : 'icon' }}" vie
</svg> </svg>
</a> </a>
</li> </li>
<li title="Tags">
<a class="hover:bg-transparent" href="{{ route('tags.index') }}">
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path
d="M3 8v4.172a2 2 0 0 0 .586 1.414l5.71 5.71a2.41 2.41 0 0 0 3.408 0l3.592-3.592a2.41 2.41 0 0 0 0-3.408l-5.71-5.71A2 2 0 0 0 9.172 6H5a2 2 0 0 0-2 2" />
<path d="m18 19l1.592-1.592a4.82 4.82 0 0 0 0-6.816L15 6m-8 4h-.01" />
</g>
</svg>
</a>
</li>
<div class="flex-1"></div> <div class="flex-1"></div>
@if (isInstanceAdmin() && !isCloud()) @if (isInstanceAdmin() && !isCloud())

View File

@ -22,20 +22,7 @@
if (typeof options.html != 'undefined') html = options.html; if (typeof options.html != 'undefined') html = options.html;
window.dispatchEvent(new CustomEvent('toast-show', { detail: { type: type, message: message, description: description, position: position, html: html } })); window.dispatchEvent(new CustomEvent('toast-show', { detail: { type: type, message: message, description: description, position: position, html: html } }));
} }" class="relative space-y-5">
window.customToastHTML = `
<div class='relative flex items-start justify-center p-4'>
<div class='flex flex-col'>
<p class='text-sm font-medium text-gray-800'>New Friend Request</p>
<p class='mt-1 text-xs leading-none text-gray-800'>Friend request from John Doe.</p>
<div class='flex mt-3'>
<button type='button' @click='burnToast(toast.id)' class='inline-flex items-center px-2 py-1 text-xs font-semibold text-white bg-indigo-600 rounded shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'>Accept</button>
<button type='button' @click='burnToast(toast.id)' class='inline-flex items-center px-2 py-1 ml-3 text-xs font-semibold text-gray-900 bg-white rounded shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50'>Decline</button>
</div>
</div>
</div>
`" class="relative space-y-5">
<template x-teleport="body"> <template x-teleport="body">
<ul x-data="{ <ul x-data="{
toasts: [], toasts: [],

View File

@ -63,6 +63,9 @@
@click.prevent="activeTab = 'resource-operations'; window.location.hash = 'resource-operations'" @click.prevent="activeTab = 'resource-operations'; window.location.hash = 'resource-operations'"
href="#">Resource Operations href="#">Resource Operations
</a> </a>
<a :class="activeTab === 'tags' && 'text-white'"
@click.prevent="activeTab = 'tags'; window.location.hash = 'tags'" href="#">Tags
</a>
<a :class="activeTab === 'danger' && 'text-white'" <a :class="activeTab === 'danger' && 'text-white'"
@click.prevent="activeTab = 'danger'; window.location.hash = 'danger'" href="#">Danger Zone @click.prevent="activeTab = 'danger'; window.location.hash = 'danger'" href="#">Danger Zone
</a> </a>
@ -112,6 +115,9 @@
<div x-cloak x-show="activeTab === 'resource-operations'"> <div x-cloak x-show="activeTab === 'resource-operations'">
<livewire:project.shared.resource-operations :resource="$application" /> <livewire:project.shared.resource-operations :resource="$application" />
</div> </div>
<div x-cloak x-show="activeTab === 'tags'">
<livewire:project.shared.tags :resource="$application" />
</div>
<div x-cloak x-show="activeTab === 'danger'"> <div x-cloak x-show="activeTab === 'danger'">
<livewire:project.shared.danger :resource="$application" /> <livewire:project.shared.danger :resource="$application" />
</div> </div>

View File

@ -112,9 +112,7 @@ class="underline" href="https://coolify.io/docs/docker/registry"
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag." helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
label="Docker Image Tag" /> label="Docker Image Tag" />
@endif @endif
@endif @endif
</div> </div>
@endif @endif

View File

@ -46,9 +46,10 @@ class="items-center justify-center box">+ Add New Resource</a>
@else @else
<div x-data="searchComponent()"> <div x-data="searchComponent()">
<x-forms.input placeholder="Search for name, fqdn..." class="w-full" x-model="search" /> <x-forms.input placeholder="Search for name, fqdn..." class="w-full" x-model="search" />
<div class="grid gap-2 pt-4 lg:grid-cols-2"> <div class="grid gap-4 pt-4 lg:grid-cols-4">
<template x-for="item in filteredApplications" :key="item.id"> <template x-for="item in filteredApplications" :key="item.id">
<a class="relative box group" :href="item.hrefLink"> <span class="relative">
<a class="h-24 box group" :href="item.hrefLink">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="pb-2 font-bold text-white" x-text="item.name"></div> <div class="pb-2 font-bold text-white" x-text="item.name"></div>
<div class="description" x-text="item.description"></div> <div class="description" x-text="item.description"></div>
@ -64,6 +65,15 @@ class="items-center justify-center box">+ Add New Resource</a>
<div class="absolute bg-warning -top-1 -left-1 badge badge-xs"></div> <div class="absolute bg-warning -top-1 -left-1 badge badge-xs"></div>
</template> </template>
</a> </a>
<div class="flex gap-1 pt-1 group-hover:text-white group min-h-6">
<template x-for="tag in item.tags">
<div class="px-2 py-1 cursor-pointer description bg-coolgray-100 hover:bg-coolgray-300"
@click.prevent="gotoTag(tag.name)" x-text="tag.name"></div>
</template>
<div class="flex items-center px-2 text-xs cursor-pointer text-neutral-500/20 group-hover:text-white hover:bg-coolgray-300"
@click.prevent="goto(item)">Add tag</div>
</div>
</span>
</template> </template>
<template x-for="item in filteredPostgresqls" :key="item.id"> <template x-for="item in filteredPostgresqls" :key="item.id">
<a class="relative box group" :href="item.hrefLink"> <a class="relative box group" :href="item.hrefLink">
@ -184,6 +194,13 @@ function searchComponent() {
mysqls: @js($mysqls), mysqls: @js($mysqls),
mariadbs: @js($mariadbs), mariadbs: @js($mariadbs),
services: @js($services), services: @js($services),
gotoTag(tag) {
window.location.href = '/tags/' + tag;
},
goto(item) {
const hrefLink = item.hrefLink;
window.location.href = `${hrefLink}#tags`;
},
get filteredApplications() { get filteredApplications() {
if (this.search === '') { if (this.search === '') {
return this.applications; return this.applications;

View File

@ -0,0 +1,13 @@
<div>
<h2>Tags</h2>
@foreach ($this->resource->tags as $tag)
<div>
<div>{{ $tag->name }}</div>
<x-forms.button isError wire:click="deleteTag('{{ $tag->id }}','{{ $tag->name }}')">Delete</x-forms.button>
</div>
@endforeach
<form wire:submit='submit'>
<x-forms.input label="Add/Assign a tag" wire:model="new_tag" wire:confirm="Are you sure you want to delete this post?" />
<x-forms.button type="submit">Add</x-forms.button>
</form>
</div>

View File

@ -0,0 +1,10 @@
<div>
<h1>Tags</h1>
<div class="flex gap-2 pt-10">
@forelse ($tags as $tag)
<a class="box" href="{{ route('tags.show', ['tag_name' => $tag->name]) }}">{{ $tag->name }}</a>
@empty
<p>No tags yet</p>
@endforelse
</div>
</div>

View File

@ -0,0 +1,18 @@
<div>
<h1>Tag: {{ $tag->name }}</h1>
<div class="">Tag details</div>
<div class="lg:w-[500px] pt-4">
<x-forms.input readonly label="Tag Deploy Webhook URL" id="webhook" />
</div>
<div class="pt-4">
<div class="flex items-end gap-2">
<h3>Resources</h3>
<x-forms.button>Redeploy All</x-forms.button>
</div>
<div class="grid gap-2 pt-4 lg:grid-cols-2">
@foreach ($resources as $resource)
<div class="box">{{ data_get($resource, 'name') }}</div>
@endforeach
</div>
</div>
</div>

View File

@ -7,6 +7,7 @@
use App\Actions\Database\StartRedis; use App\Actions\Database\StartRedis;
use App\Actions\Service\StartService; use App\Actions\Service\StartService;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\Tag;
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -36,12 +37,12 @@
'middleware' => $middlewares, 'middleware' => $middlewares,
'prefix' => 'v1' 'prefix' => 'v1'
], function () { ], function () {
Route::get('/deployments', function() { Route::get('/deployments', function () {
return ApplicationDeploymentQueue::whereIn("status", ["in_progress", "queued"])->get([ return ApplicationDeploymentQueue::whereIn("status", ["in_progress", "queued"])->get([
"id", "id",
"server_id", "server_id",
"status" "status"
])->groupBy("server_id")->map(function($item) { ])->groupBy("server_id")->map(function ($item) {
return $item; return $item;
})->toArray(); })->toArray();
}); });
@ -131,6 +132,92 @@
} }
return response()->json(['error' => "No resources found.", 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 404); return response()->json(['error' => "No resources found.", 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 404);
}); });
Route::get('/deploy/tag/{tag_name}', function (Request $request) {
$token = auth()->user()->currentAccessToken();
$team_id = data_get($token, 'team_id');
$tag_name = $request->route('tag_name');
$force = $request->query->get('force') ?? false;
if (is_null($team_id)) {
return response()->json(['error' => 'Invalid token.', 'docs' => 'https://coolify.io/docs/api/authentication'], 400);
}
$message = collect([]);
$tag = Tag::where(['name' => $tag_name, 'team_id' => $team_id])->first();
if (!$tag) {
return response()->json(['error' => 'Tag not found.', 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 404);
}
$resources = $tag->resources()->get();
if ($resources->count() === 0) {
return response()->json(['error' => 'No resources found.', 'docs' => 'https://coolify.io/docs/api/deploy-webhook'], 404);
}
foreach ($resources as $resource) {
if ($resource) {
$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.");
}
}
}
ray($resources);
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);
});
}); });
Route::middleware(['throttle:5'])->group(function () { Route::middleware(['throttle:5'])->group(function () {

View File

@ -67,6 +67,10 @@
use App\Livewire\Source\Github\Change as GitHubChange; use App\Livewire\Source\Github\Change as GitHubChange;
use App\Livewire\Subscription\Index as SubscriptionIndex; use App\Livewire\Subscription\Index as SubscriptionIndex;
use App\Livewire\Tags\Index as TagsIndex;
use App\Livewire\Tags\Show as TagsShow;
use App\Livewire\TeamSharedVariablesIndex; use App\Livewire\TeamSharedVariablesIndex;
use App\Livewire\Waitlist\Index as WaitlistIndex; use App\Livewire\Waitlist\Index as WaitlistIndex;
@ -106,7 +110,10 @@
Route::get('/settings/license', SettingsLicense::class)->name('settings.license'); Route::get('/settings/license', SettingsLicense::class)->name('settings.license');
Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile', ProfileIndex::class)->name('profile');
Route::prefix('tags')->group(function () {
Route::get('/', TagsIndex::class)->name('tags.index');
Route::get('/{tag_name}', TagsShow::class)->name('tags.show');
});
Route::prefix('team')->group(function () { Route::prefix('team')->group(function () {
Route::get('/', TeamIndex::class)->name('team.index'); Route::get('/', TeamIndex::class)->name('team.index');
Route::get('/new', TeamCreate::class)->name('team.create'); Route::get('/new', TeamCreate::class)->name('team.create');