diff --git a/app/Livewire/Deployment/Index.php b/app/Livewire/Deployment/Index.php new file mode 100644 index 000000000..acb2606c1 --- /dev/null +++ b/app/Livewire/Deployment/Index.php @@ -0,0 +1,377 @@ + ['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]+)/', + '$1', + $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, + ]); + } +} + diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 48b544ebb..41c196394 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -148,6 +148,22 @@ Servers +
  • + + + + + + + + + Deployments + +
  • + Deployments | Coolify + +
    +
    +

    Deployments

    +
    All deployments from {{ currentTeam()->name }}
    +
    + @if ($isPolling) +
    + + Updating... +
    + @endif +
    + + {{-- Filter Bar --}} +
    + @if ($shouldShowProjectFilter) + + + {{ $selectedProjectId ? ($filterOptions['projects'][$selectedProjectId] ?? 'All Projects') : 'All Projects' }} + +
    + + @foreach ($filterOptions['projects'] as $uuid => $name) + + @endforeach +
    +
    + @endif + + @if ($shouldShowServerFilter) + + + {{ $selectedServerId ? ($filterOptions['servers'][$selectedServerId] ?? 'All Servers') : 'All Servers' }} + +
    + + @foreach ($filterOptions['servers'] as $id => $name) + + @endforeach +
    +
    + @endif + + @if ($shouldShowApplicationFilter) + + + {{ $selectedApplicationId ? ($filterOptions['applications'][$selectedApplicationId] ?? 'All Applications') : 'All Applications' }} + +
    + + @foreach ($filterOptions['applications'] as $uuid => $name) + + @endforeach +
    +
    + @endif + + @if ($shouldShowSourceFilter) + + + {{ $selectedSourceId ? (collect($filterOptions['sources'])->firstWhere('id', $selectedSourceId)['name'] ?? 'All Sources') : 'All Sources' }} + +
    + + @foreach ($filterOptions['sources'] as $source) + + @endforeach +
    +
    + @endif + + + + {{ $selectedStatus ? ($filterOptions['statuses'][$selectedStatus] ?? 'All Statuses') : 'All Statuses' }} + +
    + + @foreach ($filterOptions['statuses'] as $value => $label) + + @endforeach +
    +
    + + @if ($selectedProjectId || $selectedServerId || $selectedApplicationId || $selectedSourceId || $selectedStatus) + + @endif +
    + + {{-- Deployments List --}} +
    + {{-- Table Header --}} +
    +
    ID
    +
    Status
    +
    Commit
    +
    Application
    +
    Environment
    +
    + + @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 --}} +
    +
    + {{-- Deployment ID -- Clickable to deployment page --}} + + + {{-- Status -- Not clickable --}} +
    + @if ($isActive) + + @else +
    + @endif + + {{ $statusConfig['text'] }} + +
    + + {{-- Commit Info -- Clickable to GitHub commit page --}} +
    + @if ($commitHash) + + + + {{ $branchName }} + @if ($application && $deployment->commit) + + {{ $commitHash }} + + @else + {{ $commitHash }} + @endif + @if ($commitMessage) + {{ Str::before($commitMessage, "\n") }} + @endif + @endif +
    + + {{-- Application Info -- Clickable to application configuration page --}} +
    + @if ($project && $environment && $application) + + {{ $application->name }} + + @else + + {{ $application->name }} + + @endif +
    + + {{-- Environment Badge -- Clickable to environment page --}} +
    + @if ($environment && $project) + + {{ ucfirst($environment->name) }} + + @if ($isCurrent) + + @endif + @elseif ($environment) + + {{ ucfirst($environment->name) }} + + @endif +
    + + {{-- Expand/Collapse Button for Logs --}} + {{-- Only show expand button if logs are available --}} + @if ($logLines->isNotEmpty()) + + @endif +
    + + {{-- Expandable Logs Section --}} + {{-- Shows real-time deployment logs when expanded --}} + @if ($logLines->isNotEmpty()) +
    +
    +
    + @foreach ($logLines as $line) +
    isset($line['command']) && $line['command'], + 'flex gap-2', + ])> + {{ $line['timestamp'] ?? '' }} + $line['hidden'] ?? false, + 'text-red-500' => $line['stderr'] ?? false, + 'font-bold' => isset($line['command']) && $line['command'], + 'whitespace-pre-wrap', + ])>{!! $line['line'] ?? '' !!} +
    + @endforeach +
    +
    +
    + @endif +
    + @empty +
    +

    No deployments found

    +

    Try adjusting your filters or check back later.

    +
    + @endforelse +
    + + {{-- Pagination --}} + @if ($deployments->hasPages()) +
    +
    + Showing {{ $deployments->firstItem() }} to {{ $deployments->lastItem() }} of {{ $deployments->total() }} deployments +
    +
    + @if ($deployments->onFirstPage()) + + @else + + @endif + + + Page {{ $deployments->currentPage() }} of {{ $deployments->lastPage() }} + + + @if ($deployments->hasMorePages()) + + @else + + @endif +
    +
    + @endif + + diff --git a/routes/web.php b/routes/web.php index b6c6c95ce..69c3c6faa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');