Add global deployments page with filtering capabilities

- Introduced a new Livewire component for managing and displaying deployments across all projects.
- Implemented filtering options for projects, servers, sources, and statuses.
- Added pagination and real-time updates for deployment logs.
- Updated the navigation bar to include a link to the new deployments page.
- Defined routes for accessing the global deployments page.
This commit is contained in:
Ilias Ism 2025-12-12 09:16:03 +01:00
parent 366ff95893
commit 0621a1ad92
4 changed files with 704 additions and 1 deletions

View file

@ -0,0 +1,334 @@
<?php
namespace App\Livewire\Deployment;
use App\Enums\ApplicationDeploymentStatus;
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 ?int $selectedSourceId = null;
public ?string $selectedSourceType = null;
public ?string $selectedStatus = null;
public int $perPage = 20;
protected $queryString = [
'selectedProjectId' => ['except' => ''],
'selectedServerId' => ['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 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 updatedSelectedSourceId()
{
$this->resetPage();
}
public function updatedSelectedStatus()
{
$this->resetPage();
}
public function clearFilters()
{
$this->selectedProjectId = null;
$this->selectedServerId = 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 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,
'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 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,
'shouldShowSourceFilter' => $this->shouldShowSourceFilter,
]);
}
}

View file

@ -1,3 +1,4 @@
<<<<<<< HEAD
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
x-data="{
switchWidth() {
@ -148,6 +149,22 @@
Servers
</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"

View file

@ -0,0 +1,347 @@
<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 ($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 || $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-32 flex-shrink-0">Environment</div>
<div class="w-40 flex-shrink-0">Status</div>
<div class="flex-1 min-w-0">Application</div>
<div class="min-w-0 flex-1">Commit</div>
<div class="w-48 flex-shrink-0 text-right">Created</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;
$statusUpdatedAt = $deployment->updated_at;
$statusTimeAgo = $statusUpdatedAt->diffForHumans(['short' => true]);
// 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>
{{-- 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>
{{-- 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>
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{{ $statusTimeAgo }}
</span>
</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>
{{-- 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>
{{-- Metadata -- Not clickable --}}
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 w-48 flex-shrink-0 justify-end whitespace-nowrap">
<span class="whitespace-nowrap">{{ $deployment->created_at->diffForHumans(['short' => true]) }}</span>
</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>

View file

@ -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;
@ -246,7 +247,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
});
Route::get('/servers', ServerIndex::class)->name('server.index');
// Route::get('/server/new', ServerCreate::class)->name('server.create');
// Global deployments page - shows all deployments across all projects
Route::get('/deployments', GlobalDeploymentIndex::class)->name('deployment.index');
Route::get('/server/new', ServerCreate::class)->name('server.create');
Route::prefix('server/{server_uuid}')->group(function () {
Route::get('/', ServerShow::class)->name('server.show');