mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(backups): refresh backup views live on team channel events
- Refresh application, database and volume backup lists on BackupCreated - Refresh backup execution, edit and service resource views on ServiceStatusChanged as well as ServiceChecked - Hide the deployments counter when only the current page's application is deploying, since its heading already shows "Deploying…" - Show storage section shortcuts in the service configuration sidebar - Drive server sidebar Sentinel/Security active state from activeMenu and activeSubMenu instead of route matching - Start the app sidebar expanded for new users - Drop a column from the backup executions table grid and adjust the responsive breakpoints - Update the related feature tests
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Livewire;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeploymentsIndicator extends Component
|
||||
@@ -14,6 +15,17 @@ class DeploymentsIndicator extends Component
|
||||
*/
|
||||
public string $variant = 'topbar';
|
||||
|
||||
/**
|
||||
* The application on the current page. Its heading has its own "Deploying…" indicator.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?string $currentApplicationUuid = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->currentApplicationUuid = request()->route('application_uuid');
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function deployments()
|
||||
{
|
||||
@@ -41,6 +53,16 @@ class DeploymentsIndicator extends Component
|
||||
return $this->deployments->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the counter when it would only repeat the current page's "Deploying…" indicator.
|
||||
*/
|
||||
#[Computed]
|
||||
public function isOnlyCurrentApplicationDeploying(): bool
|
||||
{
|
||||
return $this->currentApplicationUuid !== null
|
||||
&& $this->deployments->every(fn ($deployment) => $deployment->application?->uuid === $this->currentApplicationUuid);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.deployments-indicator');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application\Backup;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -11,6 +12,7 @@ use Livewire\Component;
|
||||
class Index extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use ListensToTeamChannel;
|
||||
|
||||
public Application $application;
|
||||
|
||||
@@ -18,7 +20,13 @@ class Index extends Component
|
||||
|
||||
public string $search = '';
|
||||
|
||||
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
|
||||
public function getListeners(): array
|
||||
{
|
||||
return [
|
||||
'refreshVolumeBackups' => '$refresh',
|
||||
...$this->teamChannelListeners(['BackupCreated' => '$refresh']),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\ManagesDatabaseBackup;
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -15,6 +16,7 @@ use Livewire\Features\SupportRedirects\Redirector;
|
||||
class Execution extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use ListensToTeamChannel;
|
||||
use ManagesDatabaseBackup;
|
||||
|
||||
public $database;
|
||||
@@ -42,7 +44,13 @@ class Execution extends Component
|
||||
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = '$refresh';
|
||||
}
|
||||
|
||||
return $listeners;
|
||||
return [
|
||||
...$listeners,
|
||||
...$this->teamChannelListeners([
|
||||
'ServiceChecked' => '$refresh',
|
||||
'ServiceStatusChanged' => '$refresh',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
|
||||
@@ -115,7 +115,10 @@ class BackupEdit extends Component
|
||||
|
||||
return [
|
||||
...$listeners,
|
||||
...$this->teamChannelListeners(['ServiceChecked' => 'refreshStatus']),
|
||||
...$this->teamChannelListeners([
|
||||
'ServiceChecked' => 'refreshStatus',
|
||||
'ServiceStatusChanged' => 'refreshStatus',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -10,6 +11,7 @@ use Livewire\Component;
|
||||
class ScheduledBackups extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use ListensToTeamChannel;
|
||||
|
||||
public $database;
|
||||
|
||||
@@ -19,7 +21,13 @@ class ScheduledBackups extends Component
|
||||
|
||||
public string $custom_type = 'mysql';
|
||||
|
||||
protected $listeners = ['refreshScheduledBackups'];
|
||||
public function getListeners(): array
|
||||
{
|
||||
return [
|
||||
'refreshScheduledBackups',
|
||||
...$this->teamChannelListeners(['BackupCreated' => '$refresh']),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ class ResourceCard extends Component
|
||||
public function getListeners(): array
|
||||
{
|
||||
return $this->teamChannelListeners([
|
||||
'ServiceStatusChanged' => 'refreshResource',
|
||||
'ServiceChecked' => 'refreshResource',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class Index extends Component
|
||||
'modalClosed' => 'closeScheduleModal',
|
||||
...$this->teamChannelListeners([
|
||||
'ServiceChecked' => '$refresh',
|
||||
'ServiceStatusChanged' => '$refresh',
|
||||
'BackupCreated' => '$refresh',
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use App\Traits\PreparesBackupDownloads;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
@@ -25,6 +26,7 @@ use Throwable;
|
||||
class VolumeBackups extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use ListensToTeamChannel;
|
||||
use PreparesBackupDownloads;
|
||||
use WithPagination;
|
||||
|
||||
@@ -72,6 +74,15 @@ class VolumeBackups extends Component
|
||||
|
||||
public int $perPage = 10;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
if ($this->section !== 'executions') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->teamChannelListeners(['BackupCreated' => '$refresh']);
|
||||
}
|
||||
|
||||
public function updatedPerPage(): void
|
||||
{
|
||||
$this->perPage = max(1, min(100, $this->perPage));
|
||||
|
||||
@@ -3279,7 +3279,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-executions-table-grid {
|
||||
grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;
|
||||
grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 8.5rem 5rem minmax(8rem, 1fr) 5rem;
|
||||
gap: 0.75rem;
|
||||
min-width: 66rem;
|
||||
}
|
||||
@@ -3345,11 +3345,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-executions-table-grid {
|
||||
grid-template-columns: 7.5rem minmax(9rem, 0.8fr) minmax(14rem, 1.5fr) 8rem 6rem minmax(9rem, auto);
|
||||
grid-template-columns: 7.5rem minmax(9rem, 0.8fr) minmax(14rem, 1.5fr) 8.5rem minmax(8rem, 1fr) 5rem;
|
||||
}
|
||||
|
||||
.backup-executions-table-grid > :nth-child(6),
|
||||
.backup-executions-table-grid > :nth-child(7) {
|
||||
.backup-executions-table-grid > :nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -3440,8 +3439,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
|
||||
.backup-executions-table-grid > :nth-child(4),
|
||||
.backup-executions-table-grid > :nth-child(5),
|
||||
.backup-executions-table-grid > :nth-child(6),
|
||||
.backup-executions-table-grid > :nth-child(7) {
|
||||
.backup-executions-table-grid > :nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
[
|
||||
'label' => 'Sentinel',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
|
||||
'active' => $activeMenu === 'sentinel',
|
||||
'icon' => 'shield-star',
|
||||
'group' => 'Platform',
|
||||
'visible' => ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server),
|
||||
@@ -85,8 +85,8 @@
|
||||
'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync',
|
||||
'tracks_sentinel_status' => true,
|
||||
'children' => [
|
||||
['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => request()->routeIs('server.sentinel'), 'icon' => 'settings'],
|
||||
['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => request()->routeIs('server.sentinel.logs'), 'icon' => 'file-content'],
|
||||
['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'],
|
||||
['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -182,13 +182,13 @@
|
||||
[
|
||||
'label' => 'Security',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.*'),
|
||||
'active' => $activeMenu === 'security',
|
||||
'icon' => 'shield-alert',
|
||||
'group' => 'Security',
|
||||
'visible' => auth()->user()?->can('update', $server),
|
||||
'children' => [
|
||||
['label' => 'Server Patching', 'route' => 'server.security.patches', 'active' => request()->routeIs('server.security.patches'), 'icon' => 'bandage'],
|
||||
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'active' => request()->routeIs('server.security.terminal-access'), 'icon' => 'browser-terminal', 'navigate' => false],
|
||||
['label' => 'Server Patching', 'route' => 'server.security.patches', 'active' => $activeSubMenu === 'patches', 'icon' => 'bandage'],
|
||||
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'active' => $activeSubMenu === 'terminal-access', 'icon' => 'browser-terminal', 'navigate' => false],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@props(['service', 'currentRoute', 'navOnly' => false])
|
||||
@props(['service', 'currentRoute', 'navOnly' => false, 'storageSections' => collect()])
|
||||
|
||||
@php
|
||||
$serviceRouteParameters = [
|
||||
@@ -89,6 +89,19 @@
|
||||
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
|
||||
@endif
|
||||
</a>
|
||||
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{ activeSection: '' }">
|
||||
@foreach ($storageSections as $section)
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
@click="activeSection = '{{ $section['id'] }}'; window.scrollToSettingsSection?.('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label truncate text-left"
|
||||
title="{{ $section['label'] }}">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
@auth
|
||||
<div x-data="{
|
||||
open: false,
|
||||
// New users start collapsed.
|
||||
collapsed: localStorage.getItem('sidebarCollapsed') !== 'false',
|
||||
// New users start expanded.
|
||||
collapsed: localStorage.getItem('sidebarCollapsed') === 'true',
|
||||
pageWidth: localStorage.getItem('pageWidth') || 'centered',
|
||||
sidebarReady: false,
|
||||
init() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
polling for the copy hidden by the current breakpoint (a display:none element has no box). --}}
|
||||
<div wire:poll.3000ms.visible x-data="{ expanded: false }" x-on:click.outside="expanded = false"
|
||||
x-on:keydown.escape.window="expanded = false" @class(['relative' => $variant === 'topbar'])>
|
||||
@if ($this->deploymentCount > 0)
|
||||
@if ($this->deploymentCount > 0 && ! $this->isOnlyCurrentApplicationDeploying)
|
||||
@php
|
||||
$deploymentLabel = $this->deploymentCount.' '.Str::plural('deployment', $this->deploymentCount);
|
||||
@endphp
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<div class="application-settings-section-header">
|
||||
<div>
|
||||
<h2>Executions</h2>
|
||||
<p>Review generated archives, storage availability, and backup output.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-forms.button wire:click="cleanupFailed">Clean failed backups</x-forms.button>
|
||||
@@ -40,9 +39,8 @@
|
||||
class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]">
|
||||
<span>Status</span>
|
||||
<span>Database</span>
|
||||
<span>Backup path</span>
|
||||
<span>Finished</span>
|
||||
<span>Duration</span>
|
||||
<span>Archive</span>
|
||||
<span>Time</span>
|
||||
<span>Size</span>
|
||||
<span>Availability</span>
|
||||
<span class="text-right">Actions</span>
|
||||
@@ -58,6 +56,7 @@
|
||||
'failed' => ['Failed', 'error'],
|
||||
default => [str($executionStatus)->headline(), 'neutral'],
|
||||
};
|
||||
$finishedAt = \Carbon\Carbon::parse(data_get($execution, 'finished_at') ?? data_get($execution, 'updated_at'));
|
||||
$s3Replicas = $execution->s3Replicas;
|
||||
$hasLiveS3Copies = $s3Replicas->isNotEmpty()
|
||||
? $s3Replicas->contains(fn ($replica) => $replica->s3_uploaded === true && ! $replica->s3_storage_deleted)
|
||||
@@ -90,40 +89,33 @@
|
||||
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
{{ data_get($execution, 'database_name', 'N/A') }}
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<code class="select-all truncate font-mono text-[11px] text-neutral-600 dark:text-fg-dim"
|
||||
title="Backup path: {{ data_get($execution, 'filename', 'N/A') }}">{{ data_get($execution, 'filename', 'N/A') }}</code>
|
||||
<x-copy-button :value="data_get($execution, 'filename', '')" label="Copy backup path" />
|
||||
<div class="min-w-0">
|
||||
<x-forms.copy-input :text="data_get($execution, 'filename') ?? 'No archive name'" />
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
@if ($executionStatus === 'running')
|
||||
Running now
|
||||
Running for {{ calculateDuration(data_get($execution, 'created_at'), now()) }}
|
||||
@else
|
||||
{{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
|
||||
{{ $finishedAt->diffForHumans() }}<br>
|
||||
{{ calculateDuration(data_get($execution, 'created_at'), $finishedAt) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
{{ calculateDuration(
|
||||
data_get($execution, 'created_at'),
|
||||
$executionStatus === 'running' ? now() : data_get($execution, 'finished_at'),
|
||||
) }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
<div class="tabular-nums text-neutral-500 dark:text-fg-dim">
|
||||
{{ data_get($execution, 'size') ? formatBytes(data_get($execution, 'size')) : '-' }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<x-status-badge label="Local"
|
||||
:status="data_get($execution, 'local_storage_deleted', false) ? 'Deleted' : 'Available'"
|
||||
<x-status-badge
|
||||
:status="data_get($execution, 'local_storage_deleted', false) ? 'Local deleted' : 'Local'"
|
||||
:type="data_get($execution, 'local_storage_deleted', false) ? 'neutral' : 'success'" />
|
||||
@foreach ($s3Replicas as $replica)
|
||||
<x-status-badge wire:key="database-backup-replica-{{ $replica->id }}"
|
||||
:label="$replica->s3?->name ?? 'Removed S3 storage'"
|
||||
:status="$replica->s3_storage_deleted
|
||||
? 'Deleted'
|
||||
? 'deleted'
|
||||
: match ($replica->s3_uploaded) {
|
||||
true => 'Available',
|
||||
false => 'Failed',
|
||||
default => $executionStatus === 'running' ? 'Pending' : 'Not uploaded',
|
||||
true => null,
|
||||
false => 'failed',
|
||||
default => $executionStatus === 'running' ? 'pending' : 'not uploaded',
|
||||
}"
|
||||
:type="$replica->s3_storage_deleted || $replica->s3_uploaded === null
|
||||
? 'neutral'
|
||||
@@ -131,10 +123,10 @@
|
||||
:title="$replica->message" />
|
||||
@endforeach
|
||||
@if ($s3Replicas->isEmpty() && data_get($execution, 's3_uploaded') !== null)
|
||||
<x-status-badge label="S3"
|
||||
<x-status-badge
|
||||
:status="data_get($execution, 's3_storage_deleted', false)
|
||||
? 'Deleted'
|
||||
: (data_get($execution, 's3_uploaded') ? 'Available' : 'Failed')"
|
||||
? 'S3 deleted'
|
||||
: (data_get($execution, 's3_uploaded') ? 'S3' : 'S3 failed')"
|
||||
:type="data_get($execution, 's3_storage_deleted', false)
|
||||
? 'neutral'
|
||||
: (data_get($execution, 's3_uploaded') ? 'success' : 'error')" />
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
@if (count($pendingDownloads) > 0)
|
||||
<div wire:poll.3s="checkDownloads" class="hidden"></div>
|
||||
@endif
|
||||
@if ($executions->onFirstPage())
|
||||
<div wire:poll.5000ms class="hidden"></div>
|
||||
@endif
|
||||
@if ($selectedExecution)
|
||||
<x-modal-input title="Backup execution" wireOpen="executionModalOpen" :wireIgnore="false" isLarge>
|
||||
<x-slot:content><span></span></x-slot:content>
|
||||
|
||||
@@ -6,53 +6,6 @@
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
||||
|
||||
@php
|
||||
$serviceRouteParameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
];
|
||||
|
||||
$configurationItems = collect([
|
||||
['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'],
|
||||
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
|
||||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Analytics', 'route' => 'project.service.analytics', 'icon' => 'analytics'],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'],
|
||||
['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'],
|
||||
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'danger'],
|
||||
])->filter(fn (array $item): bool => $item['visible'] ?? true)->map(fn (array $item): array => [
|
||||
...$item,
|
||||
'active' => $currentRoute === $item['route']
|
||||
|| ($item['route'] === 'project.service.scheduled-tasks.show'
|
||||
&& str($currentRoute)->startsWith('project.service.scheduled-tasks')),
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Analytics'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
|
||||
'Operations' => ['Resource Operations', 'Tags'],
|
||||
// Always last, after a divider and without a group header.
|
||||
'Danger Zone' => ['Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => collect($labels)
|
||||
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
|
||||
->filter()
|
||||
->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
// Group that holds the current page — always kept open, even if collapsed before.
|
||||
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
|
||||
|
||||
$storageSections = $applications
|
||||
->concat($databases)
|
||||
->map(fn ($resource): array => [
|
||||
@@ -63,59 +16,8 @@
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-main-nav-menu back-label="Dashboard"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
|
||||
<nav aria-label="Service settings"
|
||||
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
|
||||
aria-hidden="true"></div>
|
||||
@endunless
|
||||
@unless ($groupLabel === 'Danger Zone')
|
||||
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
|
||||
:aria-expanded="isOpen(@js($groupLabel))">
|
||||
<span>{{ $groupLabel }}</span>
|
||||
<svg class="size-3 shrink-0 opacity-60 transition-transform"
|
||||
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
@endunless
|
||||
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a @class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['hasWarning'] ?? false)
|
||||
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
|
||||
@endif
|
||||
</a>
|
||||
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{ activeSection: '' }">
|
||||
@foreach ($storageSections as $section)
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
@click="activeSection = '{{ $section['id'] }}'; window.scrollToSettingsSection?.('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label truncate text-left"
|
||||
title="{{ $section['label'] }}">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</nav>
|
||||
</x-main-nav-menu>
|
||||
<x-service.configuration-sidebar :service="$service" :current-route="$currentRoute"
|
||||
:storage-sections="$storageSections" />
|
||||
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'project.service.configuration')
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
@endcan
|
||||
@endif
|
||||
@if ($section === 'executions')
|
||||
@if ($backup && $executions->onFirstPage())
|
||||
<div wire:poll.5000ms class="hidden"></div>
|
||||
@endif
|
||||
@include('livewire.project.shared.storages.volume-backups.executions')
|
||||
@elseif ($section === 'danger')
|
||||
@include('livewire.project.shared.storages.volume-backups.danger')
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="security" />
|
||||
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="patches" />
|
||||
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
<x-application.settings-section id="server-patching-overview-section" title="Server patching"
|
||||
|
||||
@@ -7,16 +7,11 @@
|
||||
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="security" />
|
||||
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="terminal-access" />
|
||||
|
||||
<div class="application-settings-form w-full">
|
||||
<x-application.settings-section id="server-terminal-access-section" title="Terminal access"
|
||||
helper="Control dashboard terminal access for this server and its containers.">
|
||||
<x-slot:actions>
|
||||
<x-status-badge :status="$isTerminalEnabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$isTerminalEnabled ? 'success' : 'error'" />
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="sentinel" />
|
||||
<x-server.sidebar :server="$server" activeMenu="sentinel" activeSubMenu="logs" />
|
||||
<div class="application-settings-form w-full">
|
||||
<x-application.settings-section title="Sentinel logs"
|
||||
helper="Search, filter, follow, copy, or download recent output from the Sentinel container."
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="sentinel" />
|
||||
<x-server.sidebar :server="$server" activeMenu="sentinel" activeSubMenu="configuration" />
|
||||
@if ($server->isFunctional())
|
||||
<div class="w-full">
|
||||
<livewire:server.sentinel :server="$server" />
|
||||
|
||||
@@ -222,6 +222,16 @@ test('admin sees terminal link for service', function () {
|
||||
->assertSee('Terminal');
|
||||
});
|
||||
|
||||
test('service configuration page sidebar links to the file browser', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
Team::query()->update(['show_boarding' => false]);
|
||||
|
||||
$this->get(route('project.service.configuration', $this->serviceParams))
|
||||
->assertOk()
|
||||
->assertSee(route('project.service.files', $this->serviceParams), false);
|
||||
});
|
||||
|
||||
test('admin sees deploy button for service', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
@@ -114,16 +114,23 @@ it('checks current database status before queuing from a stale page', function (
|
||||
Queue::assertNotPushed(DatabaseBackupJob::class);
|
||||
})->with('database backup controls');
|
||||
|
||||
it('refreshes service backup buttons when the service status check completes', function () {
|
||||
it('refreshes service backup buttons when the service status changes', function (string $componentClass, string $event) {
|
||||
$database = $this->databases['service'];
|
||||
$database->update(['status' => 'exited:unhealthy']);
|
||||
$component = Livewire::test(Index::class, ['service' => $this->service]);
|
||||
$parameters = $componentClass === Index::class
|
||||
? ['service' => $this->service]
|
||||
: ['backup' => $this->backups['service']->fresh(), 'availableS3Storages' => collect()];
|
||||
$component = Livewire::test($componentClass, $parameters);
|
||||
$disabledButton = '/<button\s+disabled[^>]*wire:click(\.stop)?="backupNow/s';
|
||||
$database->update(['status' => 'running:healthy']);
|
||||
|
||||
$component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked');
|
||||
expect($component->html())->not->toMatch('/<button\s+disabled[^>]*wire:click.stop="backupNow/s');
|
||||
$component->dispatch('echo-private:team.'.currentTeam()->id.','.$event);
|
||||
expect($component->html())->not->toMatch($disabledButton);
|
||||
|
||||
$database->update(['status' => 'exited:unhealthy']);
|
||||
$component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked');
|
||||
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click.stop="backupNow/s');
|
||||
});
|
||||
$component->dispatch('echo-private:team.'.currentTeam()->id.','.$event);
|
||||
expect($component->html())->toMatch($disabledButton);
|
||||
})->with([
|
||||
'service list' => Index::class,
|
||||
'service settings' => BackupEdit::class,
|
||||
])->with(['ServiceChecked', 'ServiceStatusChanged']);
|
||||
|
||||
@@ -282,7 +282,7 @@ it('shows the copy status of each destination in the execution lists', function
|
||||
}
|
||||
|
||||
Livewire::test(BackupExecutions::class, ['backup' => $databaseBackup])
|
||||
->assertSeeInOrder(['First S3 Available', 'Second S3 Failed'])
|
||||
->assertSeeInOrder(['First S3', 'Second S3 failed'])
|
||||
->assertSee('Access denied')
|
||||
->assertSee('Delete the selected backup permanently from every S3 storage');
|
||||
|
||||
|
||||
@@ -74,3 +74,42 @@ it('does not show deployments of another team', function () {
|
||||
->assertDontSee('Indicator App')
|
||||
->assertDontSee('Active deployments');
|
||||
});
|
||||
|
||||
it('hides the indicator on the page of the only application that is deploying', function () {
|
||||
$this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('aria-label="Active deployments"', false);
|
||||
});
|
||||
|
||||
it('keeps the indicator on an application page when another application is deploying', function () {
|
||||
$otherApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'Other App',
|
||||
]);
|
||||
|
||||
ApplicationDeploymentQueue::create([
|
||||
'application_id' => $otherApplication->id,
|
||||
'application_name' => $otherApplication->name,
|
||||
'deployment_uuid' => 'deploy-indicator-'.fake()->uuid(),
|
||||
'deployment_url' => '/deployments/other',
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
'status' => ApplicationDeploymentStatus::QUEUED->value,
|
||||
'pull_request_id' => 0,
|
||||
]);
|
||||
|
||||
$this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('2 deployments', false)
|
||||
->assertSee('aria-label="Active deployments"', false);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,12 @@ test('toggling terminal access records enabled and disabled events', function ()
|
||||
->and(serverSettingsAuditRows('ui.server.terminal_access.disabled'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('toggling terminal access keeps the security sidebar group open', function () {
|
||||
Livewire::test(TerminalAccess::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('toggleTerminal', 'password')
|
||||
->assertSeeHtml("activeGroup: 'Security'");
|
||||
});
|
||||
|
||||
test('server settings saves record changed setting names without the sentinel token', function () {
|
||||
$newToken = 'audit-secret-sentinel-token-123';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\Heading as ServiceHeading;
|
||||
use App\Livewire\Project\Service\ResourceCard;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
@@ -81,3 +82,17 @@ test('the heading shows the service status when no resource is selected', functi
|
||||
->assertSeeInOrder(['Service status', 'Containers', 'Healthcheck'])
|
||||
->assertSee('Degraded');
|
||||
});
|
||||
|
||||
test('a compose resource row shows the new status when the service status changes', function () {
|
||||
$component = Livewire::test(ResourceCard::class, [
|
||||
'service' => $this->service->fresh(),
|
||||
'resource' => $this->exitedApp->fresh(),
|
||||
'parameters' => $this->parameters,
|
||||
])->assertSee('Exited');
|
||||
|
||||
$this->exitedApp->forceFill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->dispatch("echo-private:team.{$this->team->id},ServiceStatusChanged")
|
||||
->assertSee('Running')
|
||||
->assertDontSee('Exited');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user