mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Merge 3b157bfe9f into 201998638a
This commit is contained in:
commit
b1b6c44f1b
4 changed files with 754 additions and 0 deletions
377
app/Livewire/Deployment/Index.php
Normal file
377
app/Livewire/Deployment/Index.php
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Deployment;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public ?string $selectedProjectId = null;
|
||||
|
||||
public ?int $selectedServerId = null;
|
||||
|
||||
public ?string $selectedApplicationId = null;
|
||||
|
||||
public ?int $selectedSourceId = null;
|
||||
|
||||
public ?string $selectedSourceType = null;
|
||||
|
||||
public ?string $selectedStatus = null;
|
||||
|
||||
public int $perPage = 20;
|
||||
|
||||
protected $queryString = [
|
||||
'selectedProjectId' => ['except' => ''],
|
||||
'selectedServerId' => ['except' => ''],
|
||||
'selectedApplicationId' => ['except' => ''],
|
||||
'selectedSourceId' => ['except' => ''],
|
||||
'selectedSourceType' => ['except' => ''],
|
||||
'selectedStatus' => ['except' => ''],
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
// Initialize filters from query string if present
|
||||
}
|
||||
|
||||
public function loadDeployments(): LengthAwarePaginator
|
||||
{
|
||||
$teamId = currentTeam()->id;
|
||||
|
||||
// Use explicit joins with type casting because application_deployment_queues.application_id
|
||||
// is stored as a string (VARCHAR) but applications.id is a bigint, causing PostgreSQL
|
||||
// type mismatch errors when using whereHas relationships
|
||||
$query = ApplicationDeploymentQueue::query()
|
||||
->join('applications', function ($join) {
|
||||
$join->on(DB::raw('CAST(application_deployment_queues.application_id AS INTEGER)'), '=', 'applications.id');
|
||||
})
|
||||
->join('environments', 'applications.environment_id', '=', 'environments.id')
|
||||
->join('projects', 'environments.project_id', '=', 'projects.id')
|
||||
->where('projects.team_id', $teamId)
|
||||
->whereNull('applications.deleted_at')
|
||||
->select('application_deployment_queues.*');
|
||||
|
||||
// Filter by project
|
||||
if ($this->selectedProjectId) {
|
||||
$query->where('projects.uuid', $this->selectedProjectId);
|
||||
}
|
||||
|
||||
// Filter by server
|
||||
if ($this->selectedServerId) {
|
||||
$query->where('application_deployment_queues.server_id', $this->selectedServerId);
|
||||
}
|
||||
|
||||
// Filter by application
|
||||
if ($this->selectedApplicationId) {
|
||||
$query->where('applications.uuid', $this->selectedApplicationId);
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
if ($this->selectedSourceId && $this->selectedSourceType) {
|
||||
$query->where('applications.source_id', $this->selectedSourceId)
|
||||
->where('applications.source_type', $this->selectedSourceType);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if ($this->selectedStatus) {
|
||||
$query->where('application_deployment_queues.status', $this->selectedStatus);
|
||||
}
|
||||
|
||||
return $query->with([
|
||||
'application.environment.project',
|
||||
'application.source',
|
||||
])
|
||||
->orderBy('application_deployment_queues.created_at', 'desc')
|
||||
->paginate($this->perPage);
|
||||
}
|
||||
|
||||
public function updatedSelectedProjectId()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedSelectedServerId()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedSelectedApplicationId()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedSelectedSourceId()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedSelectedStatus()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function clearFilters()
|
||||
{
|
||||
$this->selectedProjectId = null;
|
||||
$this->selectedServerId = null;
|
||||
$this->selectedApplicationId = null;
|
||||
$this->selectedSourceId = null;
|
||||
$this->selectedSourceType = null;
|
||||
$this->selectedStatus = null;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function getFilterOptionsProperty(): array
|
||||
{
|
||||
$teamId = currentTeam()->id;
|
||||
|
||||
// Get projects available for filtering
|
||||
$projects = Project::ownedByCurrentTeamCached()
|
||||
->pluck('name', 'uuid')
|
||||
->toArray();
|
||||
|
||||
// Get servers that have deployments (only show servers with actual deployments)
|
||||
// Uses same join pattern as loadDeployments() to avoid type mismatch
|
||||
$serverIds = ApplicationDeploymentQueue::query()
|
||||
->join('applications', function ($join) {
|
||||
$join->on(DB::raw('CAST(application_deployment_queues.application_id AS INTEGER)'), '=', 'applications.id');
|
||||
})
|
||||
->join('environments', 'applications.environment_id', '=', 'environments.id')
|
||||
->join('projects', 'environments.project_id', '=', 'projects.id')
|
||||
->where('projects.team_id', $teamId)
|
||||
->whereNull('applications.deleted_at')
|
||||
->distinct('application_deployment_queues.server_id')
|
||||
->whereNotNull('application_deployment_queues.server_id')
|
||||
->pluck('application_deployment_queues.server_id')
|
||||
->toArray();
|
||||
|
||||
$servers = [];
|
||||
if (! empty($serverIds)) {
|
||||
$servers = Server::whereIn('id', $serverIds)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
// Get applications that have deployments (only show applications with actual deployments)
|
||||
// Uses same join pattern as loadDeployments() to avoid type mismatch
|
||||
$applicationUuids = ApplicationDeploymentQueue::query()
|
||||
->join('applications', function ($join) {
|
||||
$join->on(DB::raw('CAST(application_deployment_queues.application_id AS INTEGER)'), '=', 'applications.id');
|
||||
})
|
||||
->join('environments', 'applications.environment_id', '=', 'environments.id')
|
||||
->join('projects', 'environments.project_id', '=', 'projects.id')
|
||||
->where('projects.team_id', $teamId)
|
||||
->whereNull('applications.deleted_at')
|
||||
->distinct('applications.uuid')
|
||||
->pluck('applications.uuid')
|
||||
->toArray();
|
||||
|
||||
$applications = [];
|
||||
if (! empty($applicationUuids)) {
|
||||
$applications = Application::whereIn('uuid', $applicationUuids)
|
||||
->pluck('name', 'uuid')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
// Get sources (GitHub/GitLab apps) from applications with deployments
|
||||
// Applications use polymorphic relationships for sources, so we need to handle
|
||||
// both GithubApp and GitlabApp types
|
||||
$sources = [];
|
||||
$sourceData = ApplicationDeploymentQueue::query()
|
||||
->join('applications', function ($join) {
|
||||
$join->on(DB::raw('CAST(application_deployment_queues.application_id AS INTEGER)'), '=', 'applications.id');
|
||||
})
|
||||
->join('environments', 'applications.environment_id', '=', 'environments.id')
|
||||
->join('projects', 'environments.project_id', '=', 'projects.id')
|
||||
->where('projects.team_id', $teamId)
|
||||
->whereNull('applications.deleted_at')
|
||||
->whereNotNull('applications.source_id')
|
||||
->whereNotNull('applications.source_type')
|
||||
->select('applications.source_id', 'applications.source_type')
|
||||
->distinct()
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
// Resolve polymorphic source relationship
|
||||
$source = null;
|
||||
if ($row->source_type === GithubApp::class) {
|
||||
$source = GithubApp::find($row->source_id);
|
||||
} elseif ($row->source_type === GitlabApp::class) {
|
||||
$source = GitlabApp::find($row->source_id);
|
||||
}
|
||||
|
||||
if (! $source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $source->id,
|
||||
'type' => $row->source_type,
|
||||
'source' => $source,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
// Ensure unique sources by combining type and id
|
||||
->unique(function ($item) {
|
||||
return $item['type'].'-'.$item['id'];
|
||||
});
|
||||
|
||||
foreach ($sourceData as $item) {
|
||||
$source = $item['source'];
|
||||
if ($item['type'] === GithubApp::class) {
|
||||
$sources[] = [
|
||||
'id' => $source->id,
|
||||
'type' => GithubApp::class,
|
||||
'name' => 'GitHub: '.$source->name,
|
||||
];
|
||||
} elseif ($item['type'] === GitlabApp::class) {
|
||||
$sources[] = [
|
||||
'id' => $source->id,
|
||||
'type' => GitlabApp::class,
|
||||
'name' => 'GitLab: '.$source->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Get statuses
|
||||
$statuses = [];
|
||||
foreach (ApplicationDeploymentStatus::cases() as $status) {
|
||||
$statuses[$status->value] = match ($status) {
|
||||
ApplicationDeploymentStatus::QUEUED => 'Queued',
|
||||
ApplicationDeploymentStatus::IN_PROGRESS => 'In Progress',
|
||||
ApplicationDeploymentStatus::FINISHED => 'Finished',
|
||||
ApplicationDeploymentStatus::FAILED => 'Failed',
|
||||
ApplicationDeploymentStatus::CANCELLED_BY_USER => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
'projects' => $projects,
|
||||
'servers' => $servers,
|
||||
'applications' => $applications,
|
||||
'sources' => $sources,
|
||||
'statuses' => $statuses,
|
||||
];
|
||||
}
|
||||
|
||||
public function getShouldShowProjectFilterProperty(): bool
|
||||
{
|
||||
return count($this->getFilterOptionsProperty()['projects']) > 1;
|
||||
}
|
||||
|
||||
public function getShouldShowServerFilterProperty(): bool
|
||||
{
|
||||
return count($this->getFilterOptionsProperty()['servers']) > 1;
|
||||
}
|
||||
|
||||
public function getShouldShowApplicationFilterProperty(): bool
|
||||
{
|
||||
return count($this->getFilterOptionsProperty()['applications']) > 1;
|
||||
}
|
||||
|
||||
public function getShouldShowSourceFilterProperty(): bool
|
||||
{
|
||||
return count($this->getFilterOptionsProperty()['sources']) > 1;
|
||||
}
|
||||
|
||||
public function reloadDeployments()
|
||||
{
|
||||
// This method is called by wire:poll to refresh the data
|
||||
$this->render();
|
||||
}
|
||||
|
||||
public function previousPage()
|
||||
{
|
||||
$this->setPage(max(1, $this->getPage() - 1));
|
||||
}
|
||||
|
||||
public function nextPage()
|
||||
{
|
||||
$deployments = $this->loadDeployments();
|
||||
if ($deployments->hasMorePages()) {
|
||||
$this->setPage($this->getPage() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we should poll for updates.
|
||||
* Checks for ANY active deployments across the team, not just the current page,
|
||||
* to ensure new deployments are detected even if they're not visible yet.
|
||||
*/
|
||||
public function getIsPollingProperty(): bool
|
||||
{
|
||||
$teamId = currentTeam()->id;
|
||||
|
||||
// Check if there are ANY active deployments in the team (not just current page)
|
||||
// This ensures new deployments are detected even if they're not on the current page
|
||||
$hasActiveDeployments = ApplicationDeploymentQueue::query()
|
||||
->join('applications', function ($join) {
|
||||
$join->on(DB::raw('CAST(application_deployment_queues.application_id AS INTEGER)'), '=', 'applications.id');
|
||||
})
|
||||
->join('environments', 'applications.environment_id', '=', 'environments.id')
|
||||
->join('projects', 'environments.project_id', '=', 'projects.id')
|
||||
->where('projects.team_id', $teamId)
|
||||
->whereNull('applications.deleted_at')
|
||||
->whereIn('application_deployment_queues.status', [
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->exists();
|
||||
|
||||
return $hasActiveDeployments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted log lines for a deployment.
|
||||
* Only returns logs for active deployments (in_progress or queued).
|
||||
* Reuses the same log decoding logic from the deployment show page.
|
||||
*/
|
||||
public function getLogLines(ApplicationDeploymentQueue $deployment): Collection
|
||||
{
|
||||
// Only show logs for active deployments to avoid unnecessary processing
|
||||
if (!in_array($deployment->status, ['in_progress', 'queued'])) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Decode and format logs using the same helper function as deployment show page
|
||||
return decode_remote_command_output($deployment)->map(function ($logLine) {
|
||||
// Escape HTML and convert URLs to clickable links
|
||||
$logLine['line'] = e($logLine['line']);
|
||||
$logLine['line'] = preg_replace(
|
||||
'/(https?:\/\/[^\s]+)/',
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-neutral-400">$1</a>',
|
||||
$logLine['line'],
|
||||
);
|
||||
|
||||
return $logLine;
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$deployments = $this->loadDeployments();
|
||||
|
||||
return view('livewire.deployment.index', [
|
||||
'deployments' => $deployments,
|
||||
'filterOptions' => $this->getFilterOptionsProperty(),
|
||||
'isPolling' => $this->isPolling,
|
||||
'shouldShowProjectFilter' => $this->shouldShowProjectFilter,
|
||||
'shouldShowServerFilter' => $this->shouldShowServerFilter,
|
||||
'shouldShowApplicationFilter' => $this->shouldShowApplicationFilter,
|
||||
'shouldShowSourceFilter' => $this->shouldShowSourceFilter,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +148,22 @@
|
|||
<span class="menu-item-label">Servers</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a title="Deployments"
|
||||
class="{{ request()->is('deployments*') ? 'menu-item menu-item-active' : 'menu-item' }}"
|
||||
href="{{ route('deployment.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="M7 8l-4 4l4 4" />
|
||||
<path d="M17 8l4 4l-4 4" />
|
||||
<path d="M3 12a18 18 0 0 1 18 -6" />
|
||||
<path d="M21 12a18 18 0 0 1 -18 6" />
|
||||
</svg>
|
||||
Deployments
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a title="Sources" {{ wireNavigate() }}
|
||||
|
|
|
|||
356
resources/views/livewire/deployment/index.blade.php
Normal file
356
resources/views/livewire/deployment/index.blade.php
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
<div wire:poll.5s="reloadDeployments">
|
||||
<x-slot:title>Deployments | Coolify</x-slot>
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1>Deployments</h1>
|
||||
<div class="subtitle">All deployments from {{ currentTeam()->name }}</div>
|
||||
</div>
|
||||
@if ($isPolling)
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<x-loading class="w-4 h-4" />
|
||||
<span>Updating...</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Filter Bar --}}
|
||||
<div class="flex items-center gap-2 mb-6 pb-4 border-b border-neutral-300 dark:border-coolgray-200">
|
||||
@if ($shouldShowProjectFilter)
|
||||
<x-dropdown>
|
||||
<x-slot:title>
|
||||
{{ $selectedProjectId ? ($filterOptions['projects'][$selectedProjectId] ?? 'All Projects') : 'All Projects' }}
|
||||
</x-slot:title>
|
||||
<div class="flex flex-col">
|
||||
<button wire:click="$set('selectedProjectId', null)"
|
||||
class="dropdown-item {{ !$selectedProjectId ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
All Projects
|
||||
</button>
|
||||
@foreach ($filterOptions['projects'] as $uuid => $name)
|
||||
<button wire:click="$set('selectedProjectId', '{{ $uuid }}')"
|
||||
class="dropdown-item {{ $selectedProjectId === $uuid ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
{{ $name }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-dropdown>
|
||||
@endif
|
||||
|
||||
@if ($shouldShowServerFilter)
|
||||
<x-dropdown>
|
||||
<x-slot:title>
|
||||
{{ $selectedServerId ? ($filterOptions['servers'][$selectedServerId] ?? 'All Servers') : 'All Servers' }}
|
||||
</x-slot:title>
|
||||
<div class="flex flex-col">
|
||||
<button wire:click="$set('selectedServerId', null)"
|
||||
class="dropdown-item {{ !$selectedServerId ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
All Servers
|
||||
</button>
|
||||
@foreach ($filterOptions['servers'] as $id => $name)
|
||||
<button wire:click="$set('selectedServerId', {{ $id }})"
|
||||
class="dropdown-item {{ $selectedServerId === $id ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
{{ $name }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-dropdown>
|
||||
@endif
|
||||
|
||||
@if ($shouldShowApplicationFilter)
|
||||
<x-dropdown>
|
||||
<x-slot:title>
|
||||
{{ $selectedApplicationId ? ($filterOptions['applications'][$selectedApplicationId] ?? 'All Applications') : 'All Applications' }}
|
||||
</x-slot:title>
|
||||
<div class="flex flex-col">
|
||||
<button wire:click="$set('selectedApplicationId', null)"
|
||||
class="dropdown-item {{ !$selectedApplicationId ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
All Applications
|
||||
</button>
|
||||
@foreach ($filterOptions['applications'] as $uuid => $name)
|
||||
<button wire:click="$set('selectedApplicationId', '{{ $uuid }}')"
|
||||
class="dropdown-item {{ $selectedApplicationId === $uuid ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
{{ $name }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-dropdown>
|
||||
@endif
|
||||
|
||||
@if ($shouldShowSourceFilter)
|
||||
<x-dropdown>
|
||||
<x-slot:title>
|
||||
{{ $selectedSourceId ? (collect($filterOptions['sources'])->firstWhere('id', $selectedSourceId)['name'] ?? 'All Sources') : 'All Sources' }}
|
||||
</x-slot:title>
|
||||
<div class="flex flex-col">
|
||||
<button wire:click="$set('selectedSourceId', null); $set('selectedSourceType', null)"
|
||||
class="dropdown-item {{ !$selectedSourceId ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
All Sources
|
||||
</button>
|
||||
@foreach ($filterOptions['sources'] as $source)
|
||||
<button wire:click="$set('selectedSourceId', {{ $source['id'] }}); $set('selectedSourceType', '{{ $source['type'] }}')"
|
||||
class="dropdown-item {{ $selectedSourceId === $source['id'] && $selectedSourceType === $source['type'] ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
{{ $source['name'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-dropdown>
|
||||
@endif
|
||||
|
||||
<x-dropdown>
|
||||
<x-slot:title>
|
||||
{{ $selectedStatus ? ($filterOptions['statuses'][$selectedStatus] ?? 'All Statuses') : 'All Statuses' }}
|
||||
</x-slot:title>
|
||||
<div class="flex flex-col">
|
||||
<button wire:click="$set('selectedStatus', null)"
|
||||
class="dropdown-item {{ !$selectedStatus ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
All Statuses
|
||||
</button>
|
||||
@foreach ($filterOptions['statuses'] as $value => $label)
|
||||
<button wire:click="$set('selectedStatus', '{{ $value }}')"
|
||||
class="dropdown-item {{ $selectedStatus === $value ? 'bg-neutral-100 dark:bg-coolgray-100' : '' }}">
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-dropdown>
|
||||
|
||||
@if ($selectedProjectId || $selectedServerId || $selectedApplicationId || $selectedSourceId || $selectedStatus)
|
||||
<button wire:click="clearFilters"
|
||||
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
Clear filters
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Deployments List --}}
|
||||
<div class="flex flex-col">
|
||||
{{-- Table Header --}}
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-b border-neutral-300 dark:border-coolgray-200 bg-neutral-50 dark:bg-coolgray-200 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
<div class="w-20 flex-shrink-0">ID</div>
|
||||
<div class="w-40 flex-shrink-0">Status</div>
|
||||
<div class="min-w-0 flex-1">Commit</div>
|
||||
<div class="flex-1 min-w-0">Application</div>
|
||||
<div class="w-32 flex-shrink-0">Environment</div>
|
||||
</div>
|
||||
|
||||
@forelse ($deployments as $deployment)
|
||||
@php
|
||||
// Eager load relationships to avoid N+1 queries
|
||||
$application = $deployment->application;
|
||||
$environment = $application->environment ?? null;
|
||||
$project = $environment->project ?? null;
|
||||
|
||||
// Format deployment ID for display (first 9 characters)
|
||||
$shortId = substr($deployment->deployment_uuid, 0, 9);
|
||||
$status = $deployment->status;
|
||||
|
||||
// Determine if deployment is actively running (needs polling/logs)
|
||||
$isActive = in_array($status, ['in_progress', 'queued']);
|
||||
|
||||
// Status configuration for styling and display
|
||||
// Maps status values to color schemes and display text
|
||||
$statusConfig = match($status) {
|
||||
'finished' => ['color' => 'green', 'dot' => 'bg-green-500', 'text' => 'Ready', 'bg' => 'bg-green-100/80 dark:bg-green-900/30', 'textColor' => 'text-green-800 dark:text-green-200'],
|
||||
'failed' => ['color' => 'red', 'dot' => 'bg-red-500', 'text' => 'Error', 'bg' => 'bg-red-100 dark:bg-red-900/30', 'textColor' => 'text-red-800 dark:text-red-200'],
|
||||
'in_progress' => ['color' => 'yellow', 'dot' => 'bg-yellow-500', 'text' => 'In Progress', 'bg' => 'bg-yellow-100/80 dark:bg-yellow-900/30', 'textColor' => 'text-yellow-800 dark:text-yellow-200'],
|
||||
'queued' => ['color' => 'purple', 'dot' => 'bg-purple-500', 'text' => 'Queued', 'bg' => 'bg-purple-100/80 dark:bg-purple-900/30', 'textColor' => 'text-purple-800 dark:text-purple-200'],
|
||||
'cancelled-by-user' => ['color' => 'gray', 'dot' => 'bg-gray-500', 'text' => 'Cancelled', 'bg' => 'bg-gray-100 dark:bg-gray-900/30', 'textColor' => 'text-gray-800 dark:text-gray-200'],
|
||||
default => ['color' => 'gray', 'dot' => 'bg-gray-500', 'text' => ucfirst($status), 'bg' => 'bg-gray-100 dark:bg-gray-900/30', 'textColor' => 'text-gray-800 dark:text-gray-200'],
|
||||
};
|
||||
|
||||
// Format commit information for display
|
||||
$commitHash = $deployment->commit ? substr($deployment->commit, 0, 7) : null;
|
||||
$commitMessage = $deployment->commitMessage();
|
||||
$branchName = $application->git_branch ?? 'main';
|
||||
$isCurrent = false; // TODO: Determine if this is the current deployment
|
||||
|
||||
// Only load logs for active deployments to avoid unnecessary processing
|
||||
$logLines = $isActive ? $this->getLogLines($deployment) : collect();
|
||||
@endphp
|
||||
|
||||
{{-- Deployment Row with Expandable Logs --}}
|
||||
{{-- Component-level polling handles updates, no need for per-row polling --}}
|
||||
<div x-data="{ expanded: false }"
|
||||
class="border-b border-neutral-300 dark:border-coolgray-200">
|
||||
<div class="flex items-center gap-4 px-4 py-3 hover:bg-neutral-100 dark:hover:bg-black transition-colors group whitespace-nowrap">
|
||||
{{-- Deployment ID -- Clickable to deployment page --}}
|
||||
<div class="w-20 flex-shrink-0 whitespace-nowrap">
|
||||
@if ($project && $environment && $application)
|
||||
<a href="{{ route('project.application.deployment.show', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid
|
||||
]) }}"
|
||||
class="font-mono text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:underline">
|
||||
{{ $shortId }}
|
||||
</a>
|
||||
@else
|
||||
<span class="font-mono text-sm text-gray-600 dark:text-gray-400">{{ $shortId }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Status -- Not clickable --}}
|
||||
<div class="flex items-center gap-2 w-40 flex-shrink-0 whitespace-nowrap">
|
||||
@if ($isActive)
|
||||
<x-loading class="w-4 h-4 text-coollabs dark:text-warning flex-shrink-0" />
|
||||
@else
|
||||
<div class="w-2 h-2 rounded-full {{ $statusConfig['dot'] }} flex-shrink-0"></div>
|
||||
@endif
|
||||
<span class="text-sm font-medium {{ $statusConfig['textColor'] }} whitespace-nowrap">
|
||||
{{ $statusConfig['text'] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Commit Info -- Clickable to GitHub commit page --}}
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 min-w-0 flex-1">
|
||||
@if ($commitHash)
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="flex-shrink-0">{{ $branchName }}</span>
|
||||
@if ($application && $deployment->commit)
|
||||
<a href="{{ $application->gitCommitLink($deployment->commit) }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-mono text-xs flex-shrink-0 hover:text-gray-900 dark:hover:text-white hover:underline">
|
||||
{{ $commitHash }}
|
||||
</a>
|
||||
@else
|
||||
<span class="font-mono text-xs flex-shrink-0">{{ $commitHash }}</span>
|
||||
@endif
|
||||
@if ($commitMessage)
|
||||
<span class="truncate max-w-[200px]" title="{{ $commitMessage }}">{{ Str::before($commitMessage, "\n") }}</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Application Info -- Clickable to application configuration page --}}
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
@if ($project && $environment && $application)
|
||||
<a href="{{ route('project.application.configuration', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'application_uuid' => $application->uuid
|
||||
]) }}"
|
||||
class="font-medium text-sm dark:text-white truncate hover:text-gray-900 dark:hover:text-gray-200 hover:underline">
|
||||
{{ $application->name }}
|
||||
</a>
|
||||
@else
|
||||
<span class="font-medium text-sm dark:text-white truncate">
|
||||
{{ $application->name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Environment Badge -- Clickable to environment page --}}
|
||||
<div class="flex items-center gap-2 w-32 flex-shrink-0 whitespace-nowrap">
|
||||
@if ($environment && $project)
|
||||
<a href="{{ route('project.resource.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid
|
||||
]) }}"
|
||||
class="px-2 py-0.5 text-xs font-medium rounded bg-neutral-100 dark:bg-coolgray-100 text-gray-700 dark:text-gray-300 whitespace-nowrap hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
|
||||
{{ ucfirst($environment->name) }}
|
||||
</a>
|
||||
@if ($isCurrent)
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" title="Current deployment"></span>
|
||||
@endif
|
||||
@elseif ($environment)
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded bg-neutral-100 dark:bg-coolgray-100 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{{ ucfirst($environment->name) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Expand/Collapse Button for Logs --}}
|
||||
{{-- Only show expand button if logs are available --}}
|
||||
@if ($logLines->isNotEmpty())
|
||||
<button @click="expanded = !expanded"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
title="Toggle deployment logs">
|
||||
<svg class="w-5 h-5 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': expanded }"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Expandable Logs Section --}}
|
||||
{{-- Shows real-time deployment logs when expanded --}}
|
||||
@if ($logLines->isNotEmpty())
|
||||
<div x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 max-h-0"
|
||||
x-transition:enter-end="opacity-100 max-h-[500px]"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 max-h-[500px]"
|
||||
x-transition:leave-end="opacity-0 max-h-0"
|
||||
x-cloak
|
||||
class="overflow-hidden">
|
||||
<div class="px-4 pb-4 bg-neutral-50 dark:bg-coolgray-200 border-t border-neutral-200 dark:border-coolgray-300">
|
||||
<div class="mt-3 p-3 bg-white dark:bg-coolgray-100 rounded border border-neutral-200 dark:border-coolgray-300 max-h-[400px] overflow-y-auto font-mono text-xs">
|
||||
@foreach ($logLines as $line)
|
||||
<div @class([
|
||||
'mt-2' => isset($line['command']) && $line['command'],
|
||||
'flex gap-2',
|
||||
])>
|
||||
<span class="shrink-0 text-gray-500">{{ $line['timestamp'] ?? '' }}</span>
|
||||
<span @class([
|
||||
'text-success dark:text-warning' => $line['hidden'] ?? false,
|
||||
'text-red-500' => $line['stderr'] ?? false,
|
||||
'font-bold' => isset($line['command']) && $line['command'],
|
||||
'whitespace-pre-wrap',
|
||||
])>{!! $line['line'] ?? '' !!}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="py-12 text-center text-gray-600 dark:text-gray-400">
|
||||
<p class="text-lg font-medium mb-2">No deployments found</p>
|
||||
<p class="text-sm">Try adjusting your filters or check back later.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if ($deployments->hasPages())
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {{ $deployments->firstItem() }} to {{ $deployments->lastItem() }} of {{ $deployments->total() }} deployments
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($deployments->onFirstPage())
|
||||
<button disabled class="px-3 py-1.5 text-sm border border-neutral-300 dark:border-coolgray-200 rounded-md opacity-50 cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="previousPage" class="px-3 py-1.5 text-sm border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-100 dark:hover:bg-coolgray-100 transition-colors">
|
||||
Previous
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Page {{ $deployments->currentPage() }} of {{ $deployments->lastPage() }}
|
||||
</span>
|
||||
|
||||
@if ($deployments->hasMorePages())
|
||||
<button wire:click="nextPage" class="px-3 py-1.5 text-sm border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-100 dark:hover:bg-coolgray-100 transition-colors">
|
||||
Next
|
||||
</button>
|
||||
@else
|
||||
<button disabled class="px-3 py-1.5 text-sm border border-neutral-300 dark:border-coolgray-200 rounded-md opacity-50 cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ use App\Livewire\Notifications\Telegram as NotificationTelegram;
|
|||
use App\Livewire\Notifications\Webhook as NotificationWebhook;
|
||||
use App\Livewire\Profile\Index as ProfileIndex;
|
||||
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
|
||||
use App\Livewire\Deployment\Index as GlobalDeploymentIndex;
|
||||
use App\Livewire\Project\Application\Deployment\Index as DeploymentIndex;
|
||||
use App\Livewire\Project\Application\Deployment\Show as DeploymentShow;
|
||||
use App\Livewire\Project\CloneMe as ProjectCloneMe;
|
||||
|
|
@ -253,7 +254,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
});
|
||||
|
||||
Route::get('/servers', ServerIndex::class)->name('server.index');
|
||||
|
||||
// Route::get('/server/new', ServerCreate::class)->name('server.create');
|
||||
|
||||
Route::get('/deployments', GlobalDeploymentIndex::class)->name('deployment.index');
|
||||
|
||||
|
||||
Route::prefix('server/{server_uuid}')->group(function () {
|
||||
Route::get('/', ServerShow::class)->name('server.show');
|
||||
|
|
|
|||
Loading…
Reference in a new issue