deployments tab

This commit is contained in:
Christoffer Bjelke 2026-01-12 15:02:22 +01:00
parent f6a59fa2dc
commit b38f89cf06
4 changed files with 496 additions and 0 deletions

View file

@ -0,0 +1,271 @@
<?php
namespace App\Livewire\Deployments;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
class Index extends Component
{
#[Url]
public ?string $status = null;
#[Url]
public ?int $server_id = null;
#[Url]
public ?int $project_id = null;
#[Url]
public ?int $application_id = null;
public int $skip = 0;
public int $defaultTake = 10;
public bool $showNext = false;
public bool $showPrev = false;
public int $currentPage = 1;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ApplicationDeploymentQueued" => '$refresh',
];
}
public function mount()
{
$this->loadDeployments();
}
#[Computed]
public function servers(): Collection
{
return Server::ownedByCurrentTeamCached();
}
#[Computed]
public function projects(): Collection
{
return Project::ownedByCurrentTeamCached();
}
#[Computed]
public function applications(): Collection
{
$query = Application::query()
->whereHas('environment.project', function ($q) {
$q->where('team_id', currentTeam()->id);
});
if ($this->project_id) {
$query->whereHas('environment', function ($q) {
$q->where('project_id', $this->project_id);
});
}
return $query->orderBy('name')->get();
}
#[Computed]
public function statuses(): array
{
return [
'' => 'All Statuses',
ApplicationDeploymentStatus::QUEUED->value => 'Queued',
ApplicationDeploymentStatus::IN_PROGRESS->value => 'In Progress',
ApplicationDeploymentStatus::FINISHED->value => 'Finished',
ApplicationDeploymentStatus::FAILED->value => 'Failed',
ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'Cancelled',
];
}
#[Computed]
public function deployments(): Collection
{
$serverIds = $this->servers->pluck('id');
$query = ApplicationDeploymentQueue::with(['application.environment.project', 'application.destination.server'])
->whereIn('server_id', $serverIds)
->orderBy('created_at', 'desc');
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->server_id) {
$query->where('server_id', $this->server_id);
}
if ($this->project_id) {
$query->whereHas('application.environment.project', function ($q) {
$q->where('id', $this->project_id);
});
}
if ($this->application_id) {
$query->where('application_id', $this->application_id);
}
return $query->skip($this->skip)->take($this->defaultTake)->get();
}
#[Computed]
public function deploymentsCount(): int
{
$serverIds = $this->servers->pluck('id');
$query = ApplicationDeploymentQueue::whereIn('server_id', $serverIds);
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->server_id) {
$query->where('server_id', $this->server_id);
}
if ($this->project_id) {
$query->whereHas('application.environment.project', function ($q) {
$q->where('id', $this->project_id);
});
}
if ($this->application_id) {
$query->where('application_id', $this->application_id);
}
return $query->count();
}
public function loadDeployments()
{
$this->showNext = $this->deployments->count() >= $this->defaultTake && $this->deploymentsCount > ($this->skip + $this->defaultTake);
$this->showPrev = $this->skip > 0;
}
public function updatedStatus()
{
$this->resetPagination();
}
public function updatedServerId()
{
$this->resetPagination();
}
public function updatedProjectId()
{
$this->application_id = null;
$this->resetPagination();
}
public function updatedApplicationId()
{
$this->resetPagination();
}
public function resetFilters()
{
$this->status = null;
$this->server_id = null;
$this->project_id = null;
$this->application_id = null;
$this->resetPagination();
}
private function resetPagination()
{
$this->skip = 0;
$this->showPrev = false;
$this->currentPage = 1;
$this->loadDeployments();
}
public function previousPage()
{
$this->skip = max(0, $this->skip - $this->defaultTake);
$this->updateCurrentPage();
$this->loadDeployments();
}
public function nextPage()
{
$this->skip += $this->defaultTake;
$this->updateCurrentPage();
$this->loadDeployments();
}
private function updateCurrentPage()
{
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
}
public function getDeploymentUrl($deployment): ?string
{
$application = $deployment->application;
if (! $application) {
return null;
}
$environment = $application->environment;
if (! $environment) {
return null;
}
$project = $environment->project;
if (! $project) {
return null;
}
return route('project.application.deployment.show', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'application_uuid' => $application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]);
}
public function getApplicationUrl($deployment): ?string
{
$application = $deployment->application;
if (! $application) {
return null;
}
$environment = $application->environment;
if (! $environment) {
return null;
}
$project = $environment->project;
if (! $project) {
return null;
}
return route('project.application.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'application_uuid' => $application->uuid,
]);
}
public function render()
{
$this->loadDeployments();
return view('livewire.deployments.index');
}
}

View file

@ -148,6 +148,21 @@
Servers
</a>
</li>
<li>
<a title="Deployments" {{ wireNavigate() }}
class="{{ request()->is('deployments*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('deployments.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3 -5a9 9 0 0 0 6 -8a3 3 0 0 0 -3 -3a9 9 0 0 0 -8 6a6 6 0 0 0 -5 3" />
<path d="M7 14a6 6 0 0 0 -3 6a6 6 0 0 0 6 -3" />
<path d="M15 9m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
Deployments
</a>
</li>
<li>
<a title="Sources" {{ wireNavigate() }}

View file

@ -0,0 +1,206 @@
<div>
<x-slot:title>Deployments | Coolify</x-slot:title>
<h1>Deployments</h1>
<div class="subtitle">All deployments across your projects, applications, and servers.</div>
<div class="flex flex-col gap-4 pb-10" @if ($this->currentPage === 1 && (!$this->status || $this->status === 'in_progress' || $this->status === 'queued')) wire:poll.5000ms @endif>
{{-- Filters --}}
<div class="flex flex-wrap items-end gap-4 p-4 bg-white border dark:bg-coolgray-100 dark:border-coolgray-200 border-neutral-300 rounded-md">
<div class="flex-1 min-w-[150px]">
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select id="status" wire:model.live="status" class="w-full rounded-md border-gray-300 dark:border-coolgray-200 dark:bg-coolgray-200 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach ($this->statuses as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="flex-1 min-w-[150px]">
<label for="server_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Server</label>
<select id="server_id" wire:model.live="server_id" class="w-full rounded-md border-gray-300 dark:border-coolgray-200 dark:bg-coolgray-200 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="">All Servers</option>
@foreach ($this->servers as $server)
<option value="{{ $server->id }}">{{ $server->name }}</option>
@endforeach
</select>
</div>
<div class="flex-1 min-w-[150px]">
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select id="project_id" wire:model.live="project_id" class="w-full rounded-md border-gray-300 dark:border-coolgray-200 dark:bg-coolgray-200 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="">All Projects</option>
@foreach ($this->projects as $project)
<option value="{{ $project->id }}">{{ $project->name }}</option>
@endforeach
</select>
</div>
<div class="flex-1 min-w-[150px]">
<label for="application_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Application</label>
<select id="application_id" wire:model.live="application_id" class="w-full rounded-md border-gray-300 dark:border-coolgray-200 dark:bg-coolgray-200 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="">All Applications</option>
@foreach ($this->applications as $application)
<option value="{{ $application->id }}">{{ $application->name }}</option>
@endforeach
</select>
</div>
@if ($status || $server_id || $project_id || $application_id)
<x-forms.button wire:click="resetFilters">Clear Filters</x-forms.button>
@endif
</div>
{{-- Header with pagination --}}
<div class="flex items-end gap-2">
<h2>Deployments <span class="text-xs">({{ $this->deploymentsCount }})</span></h2>
@if ($this->deploymentsCount > 0)
<div class="flex items-center gap-2">
<x-forms.button disabled="{{ !$showPrev }}" wire:click="previousPage">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="m14 6l-6 6l6 6z" />
</svg>
</x-forms.button>
<span class="text-sm text-gray-600 dark:text-gray-400 px-2">
Page {{ $currentPage }} of {{ max(1, ceil($this->deploymentsCount / $defaultTake)) }}
</span>
<x-forms.button disabled="{{ !$showNext }}" wire:click="nextPage">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="m10 18l6-6l-6-6z" />
</svg>
</x-forms.button>
</div>
@endif
</div>
{{-- Deployments list --}}
<div class="flex flex-col gap-2">
@forelse ($this->deployments as $deployment)
@php
$deploymentUrl = $this->getDeploymentUrl($deployment);
$applicationUrl = $this->getApplicationUrl($deployment);
$application = $deployment->application;
$server = $deployment->application?->destination?->server ?? \App\Models\Server::find($deployment->server_id);
@endphp
<div @class([
'p-4 border-l-2 bg-white dark:bg-coolgray-100 rounded-r-md',
'border-blue-500/50 border-dashed' => $deployment->status === 'in_progress',
'border-purple-500/50 border-dashed' => $deployment->status === 'queued',
'border-white border-dashed' => $deployment->status === 'cancelled-by-user',
'border-error' => $deployment->status === 'failed',
'border-success' => $deployment->status === 'finished',
])>
<div class="flex flex-col gap-2">
{{-- Header: Status + Application + Project/Environment --}}
<div class="flex flex-wrap items-center gap-2">
<span @class([
'px-3 py-1 rounded-md text-xs font-medium shadow-xs',
'bg-blue-100/80 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => $deployment->status === 'in_progress',
'bg-purple-100/80 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300' => $deployment->status === 'queued',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $deployment->status === 'failed',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $deployment->status === 'finished',
'bg-gray-100 text-gray-700 dark:bg-gray-600/30 dark:text-gray-300' => $deployment->status === 'cancelled-by-user',
])>
@php
$statusText = match ($deployment->status) {
'finished' => 'Success',
'in_progress' => 'In Progress',
'cancelled-by-user' => 'Cancelled',
'queued' => 'Queued',
default => ucfirst($deployment->status),
};
@endphp
{{ $statusText }}
</span>
@if ($application)
<a href="{{ $applicationUrl }}" {{ wireNavigate() }} class="font-semibold text-gray-900 dark:text-white hover:underline">
{{ $deployment->application_name ?? $application->name }}
</a>
@if ($application->environment?->project)
<span class="text-gray-500 dark:text-gray-400">in</span>
<span class="text-gray-600 dark:text-gray-300">
{{ $application->environment->project->name }} / {{ $application->environment->name }}
</span>
@endif
@else
<span class="font-semibold text-gray-900 dark:text-white">
{{ $deployment->application_name ?? 'Unknown Application' }}
</span>
@endif
</div>
{{-- Server info --}}
<div class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium">Server:</span> {{ $deployment->server_name ?? $server?->name ?? 'Unknown' }}
</div>
{{-- Timestamps --}}
@if ($deployment->status !== 'queued')
<div class="text-gray-600 dark:text-gray-400 text-sm">
Started: {{ formatDateInServerTimezone($deployment->created_at, $server) }}
@if ($deployment->status !== 'in_progress' && $deployment->status !== 'cancelled-by-user' && $deployment->finished_at)
<br>Ended: {{ formatDateInServerTimezone($deployment->finished_at, $server) }}
<br>Duration: {{ calculateDuration($deployment->created_at, $deployment->finished_at) }}
<br>Finished {{ \Carbon\Carbon::parse($deployment->finished_at)->diffForHumans() }}
@elseif($deployment->status === 'in_progress')
<br>Running for: {{ calculateDuration($deployment->created_at, now()) }}
@endif
</div>
@endif
{{-- Commit info --}}
@if ($deployment->commit)
<div class="text-gray-600 dark:text-gray-400 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">Commit:</span>
@if ($application && method_exists($application, 'gitCommitLink'))
<a href="{{ $application->gitCommitLink($deployment->commit) }}" target="_blank" class="underline">
{{ substr($deployment->commit, 0, 7) }}
</a>
@else
<span>{{ substr($deployment->commit, 0, 7) }}</span>
@endif
@if ($deployment->commitMessage())
<span class="text-gray-600 dark:text-gray-400">-</span>
<span class="text-gray-600 dark:text-gray-400 truncate max-w-md">
{{ Str::limit(Str::before($deployment->commitMessage(), "\n"), 80) }}
</span>
@endif
<span class="bg-gray-200/70 dark:bg-gray-600/20 px-2 py-0.5 rounded-md text-xs text-gray-800 dark:text-gray-100 border border-gray-400/30">
@if ($deployment->is_webhook)
Webhook
@if ($deployment->pull_request_id)
| PR #{{ $deployment->pull_request_id }}
@endif
@elseif ($deployment->pull_request_id)
PR #{{ $deployment->pull_request_id }}
@elseif ($deployment->rollback === true)
Rollback
@elseif ($deployment->is_api)
API
@else
Manual
@endif
</span>
</div>
</div>
@endif
{{-- View deployment link --}}
@if ($deploymentUrl)
<div class="mt-1">
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
View deployment logs &rarr;
</a>
</div>
@endif
</div>
</div>
@empty
<div class="p-4 text-center text-gray-500 dark:text-gray-400 bg-white dark:bg-coolgray-100 rounded-md">
No deployments found.
@if ($status || $server_id || $project_id || $application_id)
<button wire:click="resetFilters" class="text-indigo-600 dark:text-indigo-400 hover:underline ml-1">Clear filters</button>
@endif
</div>
@endforelse
</div>
</div>
</div>

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\UploadController;
use App\Livewire\Admin\Index as AdminIndex;
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\Dashboard;
use App\Livewire\Deployments\Index as DeploymentsIndex;
use App\Livewire\Destination\Index as DestinationIndex;
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\ForcePasswordReset;
@ -273,6 +274,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource');
Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource');
});
Route::get('/deployments', DeploymentsIndex::class)->name('deployments.index');
Route::get('/destinations', DestinationIndex::class)->name('destination.index');
Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show');