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:
Andras Bacsai
2026-10-15 14:10:14 +02:00
parent d9890500ed
commit 0a567cb8e7
27 changed files with 209 additions and 164 deletions
+22
View File
@@ -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()
+4 -1
View File
@@ -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));
+4 -6
View File
@@ -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
+2 -2
View File
@@ -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]);
+14 -7
View File
@@ -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');
});