mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
Merge remote-tracking branch 'origin/next' into celld-one-click-service
This commit is contained in:
@@ -31,7 +31,7 @@ class Index extends Component
|
||||
public function render(): View
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable', 'latestExecution'])
|
||||
->with(['backupable', 'latestExecution', 's3'])
|
||||
->withCount('executions')
|
||||
->forApplication($this->application)
|
||||
->latest()
|
||||
|
||||
@@ -31,7 +31,7 @@ class Index extends Component
|
||||
public function render(): View
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable.resource', 'latestExecution'])
|
||||
->with(['backupable.resource', 'latestExecution', 's3'])
|
||||
->withCount('executions')
|
||||
->forService($this->service)
|
||||
->latest()
|
||||
|
||||
@@ -29,13 +29,15 @@ class ExecuteContainerCommand extends Component
|
||||
|
||||
public bool $isConnecting = false;
|
||||
|
||||
public bool $containersLoaded = false;
|
||||
|
||||
protected $rules = [
|
||||
'server' => 'required',
|
||||
'container' => 'required',
|
||||
'command' => 'required',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->containers = collect();
|
||||
@@ -52,7 +54,6 @@ class ExecuteContainerCommand extends Component
|
||||
$this->servers = $this->servers->push($server);
|
||||
}
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'database_uuid')) {
|
||||
$this->type = 'database';
|
||||
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
|
||||
@@ -64,7 +65,6 @@ class ExecuteContainerCommand extends Component
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
@@ -72,18 +72,22 @@ class ExecuteContainerCommand extends Component
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'server_uuid')) {
|
||||
$this->type = 'server';
|
||||
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
|
||||
$this->authorize('view', $this->resource);
|
||||
$this->servers = $this->servers->push($this->resource);
|
||||
$this->containersLoaded = true;
|
||||
}
|
||||
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());
|
||||
}
|
||||
|
||||
public function loadContainers()
|
||||
public function loadContainers(): void
|
||||
{
|
||||
if ($this->containersLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
if ($server->isSwarm()) {
|
||||
@@ -145,7 +149,10 @@ class ExecuteContainerCommand extends Component
|
||||
|
||||
if ($this->containers->count() === 1) {
|
||||
$this->selected_container = data_get($this->containers->first(), 'container.Names');
|
||||
$this->connectToContainer();
|
||||
}
|
||||
|
||||
$this->containersLoaded = true;
|
||||
}
|
||||
|
||||
public function updatedSelectedContainer()
|
||||
|
||||
@@ -28,7 +28,7 @@ class All extends Component
|
||||
/**
|
||||
* Precomputed per-volume backup badge/link data.
|
||||
*
|
||||
* @var array<int, array{enabled: bool, url: ?string}>
|
||||
* @var array<int, array{enabled: bool, s3: bool, url: ?string}>
|
||||
*/
|
||||
public array $volumeBackupMeta = [];
|
||||
|
||||
@@ -241,6 +241,7 @@ class All extends Component
|
||||
|
||||
$this->volumeBackupMeta[(int) $storage->id] = [
|
||||
'enabled' => $enabled,
|
||||
's3' => $enabled && (bool) $backup?->save_s3,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ class Terminal extends Component
|
||||
|
||||
public bool $isTerminalConnected = false;
|
||||
|
||||
public bool $autoStart = false;
|
||||
|
||||
public string $variant = 'default';
|
||||
|
||||
private function checkShellAvailability(Server $server, string $container): bool
|
||||
|
||||
+111
-31
@@ -500,11 +500,6 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .applica
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-block::before {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] {
|
||||
color: #52525b !important;
|
||||
}
|
||||
@@ -678,7 +673,7 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell {
|
||||
inset: 0 !important;
|
||||
z-index: 100000 !important;
|
||||
width: 100vw !important;
|
||||
height: 100dvh !important;
|
||||
height: auto !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
@@ -707,25 +702,111 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.terminal-loading-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.75);
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-loading-label {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.terminal-session-expiry {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.6);
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-expiry,
|
||||
html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.terminal-target-picker {
|
||||
color: rgb(255 255 255 / 0.75);
|
||||
background: rgb(0 0 0 / 0.18);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.terminal-session-panel {
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-panel {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-target-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--terminal-scrollbar, rgb(255 255 255 / 0.3)) transparent;
|
||||
}
|
||||
|
||||
.terminal-target-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-target-list::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 9999px;
|
||||
background: var(--terminal-scrollbar, rgb(255 255 255 / 0.3));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.terminal-target-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker {
|
||||
color: #52525b;
|
||||
background: #fff;
|
||||
border-color: #d4d4d8;
|
||||
box-shadow: 0 18px 50px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker [class*="text-white"] {
|
||||
color: #52525b !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker input {
|
||||
color: #27272a !important;
|
||||
background: #f4f4f5 !important;
|
||||
border-color: #d4d4d8 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker input::placeholder {
|
||||
color: #71717a !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker button:hover {
|
||||
color: #18181b !important;
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-theme-trigger {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-target-trigger {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-theme-trigger:hover,
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-target-trigger:hover {
|
||||
color: #18181b;
|
||||
background: rgb(0 0 0 / 0.06);
|
||||
}
|
||||
|
||||
.application-console-block {
|
||||
/* Keep backdrop blur on a pseudo-element so position:fixed descendants
|
||||
(terminal fullscreen) are not trapped by a backdrop-filter containing block. */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.application-console-block::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: rgb(0 0 0 / 0.56);
|
||||
backdrop-filter: blur(10px);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.terminal-fullscreen-btn {
|
||||
display: flex;
|
||||
width: 1.75rem;
|
||||
@@ -733,10 +814,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--terminal-scrollbar, rgb(255 255 255 / 0.65));
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease,
|
||||
@@ -744,8 +824,8 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
|
||||
}
|
||||
|
||||
.terminal-fullscreen-btn:hover {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: rgb(255 255 255 / 0.95);
|
||||
background: color-mix(in srgb, var(--terminal-scrollbar, #fff) 18%, transparent);
|
||||
color: var(--terminal-scrollbar, #fff);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2149,7 +2229,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem;
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem;
|
||||
}
|
||||
|
||||
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
|
||||
@@ -2383,11 +2463,12 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.5fr) 6rem 7.5rem minmax(8rem, 1fr);
|
||||
grid-template-columns: minmax(10rem, 1.5fr) 6rem 7.5rem 6.5rem;
|
||||
}
|
||||
|
||||
.backup-table-grid > :nth-child(3),
|
||||
.backup-table-grid > :nth-child(6) {
|
||||
.backup-table-grid > :nth-child(6),
|
||||
.backup-table-grid > :nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2443,11 +2524,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 7.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) 7.5rem 6.5rem;
|
||||
}
|
||||
|
||||
.backup-table-grid > :nth-child(2),
|
||||
.backup-table-grid > :nth-child(5) {
|
||||
.backup-table-grid > :nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ export function initializeTerminalComponent() {
|
||||
return {
|
||||
fullscreen: false,
|
||||
terminalActive: false,
|
||||
starting: false,
|
||||
message: '(connection closed)',
|
||||
term: null,
|
||||
fitAddon: null,
|
||||
@@ -203,6 +204,7 @@ export function initializeTerminalComponent() {
|
||||
: 'system',
|
||||
|
||||
init() {
|
||||
this.starting = this.$el.dataset.autoStart === 'true';
|
||||
this.themeObserver = new MutationObserver(() => {
|
||||
if (this.selectedTheme === 'system') {
|
||||
applicationTerminalThemes.system = createSystemTerminalTheme();
|
||||
@@ -676,6 +678,7 @@ export function initializeTerminalComponent() {
|
||||
}
|
||||
|
||||
if (event.data === 'pty-ready') {
|
||||
this.starting = false;
|
||||
if (!this.term._initialized) {
|
||||
this.term.open(document.getElementById('terminal'));
|
||||
this.term._initialized = true;
|
||||
@@ -715,6 +718,7 @@ export function initializeTerminalComponent() {
|
||||
// Notify parent component that terminal is connected
|
||||
this.$wire.dispatch('terminalConnected');
|
||||
} else if (event.data === 'unprocessable') {
|
||||
this.starting = false;
|
||||
if (this.term) this.term.reset();
|
||||
this.terminalActive = false;
|
||||
this.lastSentCommand = null;
|
||||
@@ -724,6 +728,7 @@ export function initializeTerminalComponent() {
|
||||
// Notify parent component that terminal connection failed
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (event.data === 'pty-exited') {
|
||||
this.starting = false;
|
||||
this.exitFullscreen();
|
||||
this.mobileToolbarCollapsed = false;
|
||||
this.terminalActive = false;
|
||||
|
||||
@@ -73,8 +73,10 @@
|
||||
const viewportBottom = viewportTop + viewportHeight;
|
||||
const availableWidth = Math.max(0, viewportWidth - padding * 2);
|
||||
const availableHeight = Math.max(0, viewportHeight - padding * 2);
|
||||
const maxPopupWidth = 320;
|
||||
const popupWidth = Math.min(maxPopupWidth, availableWidth);
|
||||
|
||||
this.style = `max-width: ${availableWidth}px; max-height: ${availableHeight}px; overflow-y: auto;`;
|
||||
this.style = `max-width: ${popupWidth}px; max-height: ${availableHeight}px; overflow-y: auto;`;
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
@@ -120,7 +122,7 @@
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
:style="style"
|
||||
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
|
||||
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] whitespace-normal"
|
||||
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
|
||||
<div class="px-3 py-2.5 text-[13px] leading-5">
|
||||
{!! $helper !!}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
@props(['themes', 'themeNames', 'themeAccents'])
|
||||
|
||||
<div class="relative ml-auto shrink-0" @click.outside="themeOpen = false">
|
||||
<button type="button"
|
||||
class="terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
@click="themeOpen = !themeOpen" aria-label="Choose terminal theme" :aria-expanded="themeOpen">
|
||||
<span class="size-2 rounded-full ring-1 ring-white/20"
|
||||
:style="{ backgroundColor: @js($themeAccents)[consoleTheme] }"></span>
|
||||
<span x-text="@js($themeNames)[consoleTheme]"></span>
|
||||
<svg class="size-2.5 text-white/35" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
|
||||
class="console-theme-selector absolute top-11 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
@foreach ($themes as $theme)
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
@click="setTheme('{{ $theme['key'] }}')">
|
||||
<span class="h-3 w-5 rounded-full border border-white/10"
|
||||
style="background: {{ $theme['background'] }}"></span>
|
||||
<span class="flex-1">{{ $theme['name'] }}</span>
|
||||
<svg x-show="consoleTheme === '{{ $theme['key'] }}'" class="size-3 text-[#fcd452]"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,6 +178,7 @@
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
@@ -212,6 +213,10 @@
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span title="{{ $backup->save_s3 ? ($backup->s3?->name ?? 'S3 storage unavailable') : 'S3 storage is not configured' }}">
|
||||
<x-status-badge :status="$backup->save_s3 ? ($backup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
|
||||
:type="$backup->save_s3 ? ($backup->s3 ? 'success' : 'error') : 'neutral'" />
|
||||
</span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
|
||||
@@ -39,17 +39,10 @@
|
||||
@endif
|
||||
|
||||
@if ($isApplication && $resource->fqdn)
|
||||
<div class="mt-2 flex min-w-0 items-center gap-1.5">
|
||||
<div class="mt-2 min-w-0">
|
||||
<span class="min-w-0 truncate text-xs text-neutral-500 dark:text-fg-dim">
|
||||
{{ $resource->fqdn }}
|
||||
</span>
|
||||
@can('update', $service)
|
||||
<a class="icon-button shrink-0" title="Edit domains" aria-label="Edit domains"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('project.service.domains', $parameters) }}">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -63,6 +56,14 @@
|
||||
<x-reicon name="database" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
@if ($isApplication && $resource->fqdn)
|
||||
@can('update', $service)
|
||||
<a class="icon-button" title="Manage domains" aria-label="Manage domains" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.domains', $parameters) }}">
|
||||
<x-reicon name="globe" class="size-4" />
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
@@ -109,6 +110,14 @@
|
||||
<x-reicon name="database" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
@if ($isApplication && $resource->fqdn)
|
||||
@can('update', $service)
|
||||
<a class="icon-button" title="Manage domains" aria-label="Manage domains" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.domains', $parameters) }}">
|
||||
<x-reicon name="globe" class="size-4" />
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
@@ -213,6 +214,10 @@
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span title="{{ $backup->save_s3 ? ($backup->s3?->name ?? 'S3 storage unavailable') : 'S3 storage is not configured' }}">
|
||||
<x-status-badge :status="$backup->save_s3 ? ($backup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
|
||||
:type="$backup->save_s3 ? ($backup->s3 ? 'success' : 'error') : 'neutral'" />
|
||||
</span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
<div>
|
||||
@if ($resource->getMorphClass() === 'App\Models\Application')
|
||||
@php
|
||||
$primaryStatus = str($resource->realStatus());
|
||||
$primaryStatusType = match (true) {
|
||||
$primaryStatus->startsWith('running') => 'success',
|
||||
$primaryStatus->startsWith('exited') => 'error',
|
||||
$primaryStatus->startsWith(['starting', 'restarting']) => 'warning',
|
||||
default => 'neutral',
|
||||
};
|
||||
$primaryStatusLabel = $primaryStatus->before(':')->headline()->value() ?: 'Unknown';
|
||||
$additionalDestinations = $resource->additional_networks;
|
||||
$hasAdditionalDestinations = $additionalDestinations->isNotEmpty();
|
||||
@endphp
|
||||
@@ -41,21 +33,13 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
@if ($primaryStatus->startsWith('running'))
|
||||
<x-status.running :status="$primaryStatus->value()" />
|
||||
@elseif ($primaryStatus->startsWith(['starting', 'restarting']))
|
||||
<x-status.restarting :status="$primaryStatus->value()" />
|
||||
@elseif ($primaryStatus->startsWith('exited'))
|
||||
<x-status.stopped :status="$primaryStatus->value()" />
|
||||
@else
|
||||
<x-status-badge :status="$primaryStatusLabel" :type="$primaryStatusType" />
|
||||
@endif
|
||||
<x-status-summary :status="$resource->status" />
|
||||
@if ($hasAdditionalDestinations)
|
||||
<x-forms.button canGate="deploy" :canResource="$resource"
|
||||
wire:click="redeploy('{{ data_get($resource, 'destination.id') }}','{{ data_get($resource, 'destination.server.id') }}')">
|
||||
Deploy
|
||||
</x-forms.button>
|
||||
@if ($primaryStatus->startsWith('running'))
|
||||
@if (str($resource->status)->startsWith('running'))
|
||||
<x-forms.button isError canGate="deploy" :canResource="$resource"
|
||||
wire:click="stop('{{ data_get($resource, 'destination.server.id') }}')">
|
||||
Stop
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div>
|
||||
<div @if ($type !== 'server') wire:init="loadContainers" @endif>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > Terminal | Coolify
|
||||
</x-slot>
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
@php
|
||||
$consoleUnavailable = ($type === 'server' && (! $servers->first()->isTerminalEnabled() || ! $servers->first()->isFunctional()))
|
||||
|| ($type !== 'server' && $containers->isEmpty());
|
||||
|| ($type !== 'server' && $containersLoaded && $containers->isEmpty());
|
||||
$consoleThemes = [
|
||||
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
|
||||
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
|
||||
@@ -70,11 +70,15 @@
|
||||
</section>
|
||||
@else
|
||||
<section class="mt-8 mb-0! h-[calc(100dvh-8rem)] min-h-[32rem] w-full max-w-[1180px] xl:mt-0"
|
||||
x-on:terminal-theme-selected="setTheme($event.detail.theme)"
|
||||
x-on:terminal-starting.window="syncTheme()"
|
||||
x-data="{
|
||||
themeKeys: @js($consoleThemeKeys),
|
||||
themeAccents: @js($consoleThemeAccents),
|
||||
consoleTheme: 'system',
|
||||
themeOpen: false,
|
||||
containerOpen: false,
|
||||
targetChosen: @js($selected_container !== 'default'),
|
||||
selectedContainer: @entangle('selected_container').live,
|
||||
containerOptions: @js($containerOptions),
|
||||
init() {
|
||||
@@ -88,8 +92,14 @@
|
||||
localStorage.setItem('coolify-console-theme', theme);
|
||||
window.dispatchEvent(new CustomEvent('terminal-theme-change', { detail: { theme } }));
|
||||
},
|
||||
syncTheme() {
|
||||
const savedTheme = localStorage.getItem('coolify-console-theme');
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
|
||||
},
|
||||
selectContainer(value) {
|
||||
window.dispatchEvent(new CustomEvent('terminal-starting'));
|
||||
this.selectedContainer = value;
|
||||
this.targetChosen = true;
|
||||
this.containerOpen = false;
|
||||
},
|
||||
get selectedContainerLabel() {
|
||||
@@ -97,12 +107,15 @@
|
||||
?? 'Choose a container';
|
||||
},
|
||||
}">
|
||||
<div class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg"
|
||||
:data-console-theme="consoleTheme">
|
||||
<div data-terminal-session-canvas
|
||||
class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg p-3 sm:p-6"
|
||||
:data-console-theme="consoleTheme"
|
||||
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
|
||||
<header
|
||||
class="application-console-header flex h-[30px] shrink-0 items-center border-b border-white/[0.12] px-2.5 text-[11px] text-white select-none">
|
||||
class="terminal-session-toolbar absolute top-3 right-3 left-3 z-20 flex items-center gap-3 text-white select-none">
|
||||
@if ($type === 'server')
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2" x-data="{ autoConnected: false }"
|
||||
<div class="terminal-session-target-trigger flex h-8 min-w-0 max-w-sm flex-1 items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70"
|
||||
x-data="{ autoConnected: false }"
|
||||
@if ($servers->first()->isTerminalEnabled() && $servers->first()->isFunctional())
|
||||
x-on:terminal-websocket-ready.window="if (!autoConnected) {
|
||||
autoConnected = true;
|
||||
@@ -110,31 +123,25 @@
|
||||
}"
|
||||
@endif>
|
||||
<x-reicon name="browser-terminal" class="size-3.5 shrink-0 text-white/55" />
|
||||
<span class="min-w-0 truncate text-[11px] font-semibold text-white/80">
|
||||
<span class="min-w-0 truncate font-semibold text-white/80">
|
||||
{{ $servers->first()->name }}
|
||||
</span>
|
||||
<div class="hidden items-center gap-1.5 text-[10px] font-medium text-white/40"
|
||||
wire:loading.flex wire:target="connectToServer">
|
||||
<x-loading />
|
||||
Connecting
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2" x-data="{ autoConnected: false }"
|
||||
x-on:terminal-websocket-ready.window="if ({{ $containers->count() }} === 1 && !autoConnected) {
|
||||
autoConnected = true;
|
||||
$nextTick(() => $wire.dispatchSelf('connectToContainer'));
|
||||
}">
|
||||
<x-reicon name="browser-terminal" class="size-3.5 shrink-0 text-white/55" />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
@if ($containers->count() === 1)
|
||||
<span class="min-w-0 truncate text-[11px] font-semibold text-white/80">
|
||||
<div class="terminal-session-target-trigger flex h-8 min-w-0 max-w-sm items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70">
|
||||
<x-reicon name="browser-terminal" class="size-3.5 shrink-0 text-white/55" />
|
||||
<span class="min-w-0 truncate font-semibold text-white/80">
|
||||
{{ data_get($containers->first(), 'container.Names') }}
|
||||
· {{ data_get($containers->first(), 'server.name') }}
|
||||
</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="relative min-w-0" @click.outside="containerOpen = false">
|
||||
<div x-cloak x-show="targetChosen" class="relative min-w-0"
|
||||
@click.outside="containerOpen = false">
|
||||
<button type="button"
|
||||
class="flex h-6 max-w-[34rem] min-w-48 items-center gap-1.5 rounded-md px-2 text-[11px] font-semibold text-white/80 transition-colors hover:bg-white/[0.08]"
|
||||
class="terminal-session-target-trigger flex h-8 max-w-[34rem] min-w-48 items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
@click="containerOpen = !containerOpen">
|
||||
<span class="min-w-0 flex-1 truncate text-left"
|
||||
x-text="selectedContainerLabel"></span>
|
||||
@@ -145,7 +152,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="containerOpen" x-transition.origin.top.left
|
||||
class="absolute top-7 left-0 z-50 max-h-72 w-80 overflow-y-auto rounded-lg border border-white/[0.1] bg-[#111113] p-1 shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
class="terminal-target-picker terminal-target-list absolute top-11 left-0 z-50 max-h-72 w-80 overflow-y-auto rounded-lg border p-1 shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
|
||||
<template x-for="option in containerOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
@@ -163,51 +170,41 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="hidden items-center gap-1.5 text-[10px] font-medium text-white/40"
|
||||
wire:loading.flex wire:target="selected_container,connectToContainer">
|
||||
<x-loading />
|
||||
Connecting
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="relative ml-auto shrink-0" @click.outside="themeOpen = false">
|
||||
<button type="button"
|
||||
class="flex h-6 items-center gap-1.5 rounded-md px-2 text-[10px] font-medium text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white/90"
|
||||
@click="themeOpen = !themeOpen" aria-label="Choose terminal theme">
|
||||
<span class="size-2 rounded-full ring-1 ring-white/20"
|
||||
:style="{ backgroundColor: @js($consoleThemeAccents)[consoleTheme] }"></span>
|
||||
<span x-text="@js($consoleThemeNames)[consoleTheme]"></span>
|
||||
<svg class="size-2.5 text-white/35" viewBox="0 0 12 12" fill="none"
|
||||
aria-hidden="true">
|
||||
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor"
|
||||
stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
|
||||
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
@foreach ($consoleThemes as $theme)
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
@click="setTheme('{{ $theme['key'] }}')">
|
||||
<span class="h-3 w-5 rounded-full border border-white/10"
|
||||
style="background: {{ $theme['background'] }}"></span>
|
||||
<span class="flex-1">{{ $theme['name'] }}</span>
|
||||
<svg x-show="consoleTheme === '{{ $theme['key'] }}'"
|
||||
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
|
||||
aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
|
||||
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
|
||||
:theme-accents="$consoleThemeAccents" />
|
||||
</header>
|
||||
|
||||
<div class="terminal-session-panel mt-8 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="application-console-block min-h-0 flex-1">
|
||||
<livewire:project.shared.terminal variant="application" />
|
||||
@if ($type !== 'server' && $containers->count() > 1)
|
||||
<div x-cloak x-show="!targetChosen" data-terminal-target-picker="launcher"
|
||||
class="absolute inset-0 z-20 flex items-start justify-start p-6 sm:p-10">
|
||||
<div class="terminal-target-picker w-full max-w-md rounded-lg border p-2 shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
|
||||
<div class="px-2 pt-1 pb-2">
|
||||
<div class="text-sm font-semibold text-white/80">Start a terminal session</div>
|
||||
<div class="mt-0.5 text-[11px] text-white/45">
|
||||
Choose a container to start a session
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<template x-for="option in containerOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
@click="selectContainer(option.value)">
|
||||
<x-reicon name="layers" class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="option.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<livewire:project.shared.terminal variant="application"
|
||||
:auto-start="$type === 'server' || ! $containersLoaded || $containers->count() === 1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
if (! $form) {
|
||||
continue;
|
||||
}
|
||||
$backupMeta = $volumeBackupMeta[$id] ?? ['enabled' => false, 'url' => null];
|
||||
$backupMeta = $volumeBackupMeta[$id] ?? ['enabled' => false, 's3' => false, 'url' => null];
|
||||
$hasEnabledBackup = $backupMeta['enabled'];
|
||||
$hasS3Backup = $backupMeta['s3'];
|
||||
$backupUrl = $backupMeta['url'];
|
||||
$inputsReadonly = $form['isReadOnly'];
|
||||
$displayHostPath = filled($form['hostPath']) ? $form['hostPath'] : '—';
|
||||
@@ -78,7 +79,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="volumes-col-backup data-table-cell-check">
|
||||
<div class="volumes-col-backup flex items-center justify-center gap-1.5">
|
||||
<span class="volumes-mobile-label volumes-field-label">Backup</span>
|
||||
@if ($hasEnabledBackup)
|
||||
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
|
||||
@@ -87,6 +88,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
|
||||
title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
|
||||
{{ $hasS3Backup ? 'S3' : 'Local' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
@endif
|
||||
@@ -153,7 +158,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="volumes-col-backup data-table-cell-check">
|
||||
<div class="volumes-col-backup flex items-center justify-center gap-1.5">
|
||||
<span class="volumes-mobile-label volumes-field-label">Backup</span>
|
||||
@if ($hasEnabledBackup)
|
||||
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
|
||||
@@ -162,6 +167,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
|
||||
title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
|
||||
{{ $hasS3Backup ? 'S3' : 'Local' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
@endif
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
$isApplicationConsole = $variant === 'application';
|
||||
@endphp
|
||||
|
||||
<div id="terminal-container" x-data="terminalData()"
|
||||
<div id="terminal-container" x-data="terminalData()" data-auto-start="{{ $autoStart ? 'true' : 'false' }}"
|
||||
x-on:terminal-starting.window="starting = true; setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')"
|
||||
x-on:terminal-theme-change.window="setTerminalTheme($event.detail.theme)"
|
||||
@class([
|
||||
'group/terminal relative h-full min-h-0 bg-transparent' => $isApplicationConsole,
|
||||
@@ -40,15 +41,15 @@
|
||||
<div x-ref="terminalWrapper" wire:ignore
|
||||
:data-console-theme="fullscreen ? selectedTheme : null"
|
||||
:class="fullscreen
|
||||
? 'terminal-fullscreen-shell fixed inset-0 z-[100000] m-0 flex h-[100dvh] w-screen max-w-none flex-col overflow-hidden rounded-none p-0'
|
||||
? 'terminal-fullscreen-shell fixed inset-0 z-[100000] m-0 flex w-screen max-w-none flex-col overflow-hidden rounded-none p-0'
|
||||
: @js($isApplicationConsole
|
||||
? 'relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-transparent'
|
||||
: 'relative flex w-full h-full max-h-[510px] flex-col py-4 mx-auto')">
|
||||
@if ($isApplicationConsole)
|
||||
<div x-show="!terminalActive" x-cloak
|
||||
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-black/35">
|
||||
<div class="flex items-center gap-2 text-[11px] text-[#6e6e74]">
|
||||
<svg x-show="connectionState === 'connecting' || connectionState === 'reconnecting'"
|
||||
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-transparent">
|
||||
<div class="terminal-loading-label flex items-center gap-2">
|
||||
<svg x-show="starting || connectionState === 'connecting' || connectionState === 'reconnecting'"
|
||||
class="size-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
|
||||
stroke-width="3" />
|
||||
@@ -56,11 +57,11 @@
|
||||
stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span
|
||||
x-text="connectionState === 'connecting' ? 'connecting…' : (connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : 'choose a container to start a session')"></span>
|
||||
x-text="connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : (starting ? 'connecting…' : (connectionState === 'connecting' ? 'connecting…' : 'choose a container to start a session'))"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="terminalActive" x-cloak
|
||||
class="pointer-events-none absolute right-3 bottom-2 z-20 font-mono text-[10px] text-white/25"
|
||||
class="terminal-session-expiry pointer-events-none absolute right-3 bottom-2 z-20 font-mono"
|
||||
x-text="terminalSessionRemainingLabel()">
|
||||
</div>
|
||||
@else
|
||||
@@ -78,8 +79,7 @@
|
||||
: 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24')
|
||||
: @js($isApplicationConsole
|
||||
? 'terminal-host relative min-h-0 flex-1 overflow-hidden pt-[5px] pr-px pb-[5px] pl-1 bg-transparent'
|
||||
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')"
|
||||
x-show="terminalActive">
|
||||
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')">
|
||||
</div>
|
||||
|
||||
<div x-show="terminalActive" x-cloak
|
||||
|
||||
@@ -58,12 +58,16 @@
|
||||
</header>
|
||||
|
||||
<section class="terminal-page-console min-h-0 w-full flex-1 overflow-hidden"
|
||||
x-on:terminal-theme-selected="setTheme($event.detail.theme)"
|
||||
x-on:terminal-starting.window="syncTheme()"
|
||||
x-data="{
|
||||
targets: @js($terminalOptions),
|
||||
currentTargetLabel: @js($terminalLabel),
|
||||
targetChosen: @js($selected_uuid !== 'default'),
|
||||
targetOpen: false,
|
||||
targetSearch: '',
|
||||
themeKeys: @js($consoleThemeKeys),
|
||||
themeAccents: @js($consoleThemeAccents),
|
||||
consoleTheme: 'system',
|
||||
themeOpen: false,
|
||||
get filteredTargets() {
|
||||
@@ -83,18 +87,86 @@
|
||||
localStorage.setItem('coolify-console-theme', theme);
|
||||
window.dispatchEvent(new CustomEvent('terminal-theme-change', { detail: { theme } }));
|
||||
},
|
||||
syncTheme() {
|
||||
const savedTheme = localStorage.getItem('coolify-console-theme');
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
|
||||
},
|
||||
async selectTarget(target) {
|
||||
window.dispatchEvent(new CustomEvent('terminal-starting'));
|
||||
this.currentTargetLabel = target.label;
|
||||
this.targetChosen = true;
|
||||
this.targetOpen = false;
|
||||
this.targetSearch = '';
|
||||
await $wire.set('selected_uuid', target.value);
|
||||
await $wire.connectToContainer();
|
||||
}
|
||||
}">
|
||||
<div class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg"
|
||||
:data-console-theme="consoleTheme">
|
||||
@if ($selected_uuid === 'default')
|
||||
<div data-terminal-target-canvas
|
||||
class="application-console-shell relative flex h-full min-h-0 w-full items-center justify-center overflow-hidden rounded-lg p-6"
|
||||
:data-console-theme="consoleTheme"
|
||||
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
|
||||
<div class="absolute top-3 right-3 z-20">
|
||||
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
|
||||
:theme-accents="$consoleThemeAccents" />
|
||||
</div>
|
||||
<div data-terminal-target-picker="page"
|
||||
class="terminal-target-picker z-10 w-full max-w-lg overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
|
||||
<div class="border-b border-white/[0.08] p-4">
|
||||
<h2 class="text-base font-semibold text-white/85">Start a terminal session</h2>
|
||||
<p class="mt-1 text-sm text-white/55">
|
||||
{{ $isLoadingContainers ? 'Finding available servers and containers…' : 'Choose a server or container. The terminal will open after you select a target.' }}
|
||||
</p>
|
||||
@if (! $isLoadingContainers && $servers->isNotEmpty())
|
||||
<div class="relative mt-3">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-white/40" />
|
||||
<input x-model.debounce.100ms="targetSearch" type="search" placeholder="Filter targets…"
|
||||
class="w-full rounded-md! border-white/[0.1]! bg-black/15! py-2! pr-3! pl-9! text-sm! text-white! shadow-none! placeholder:text-white/35 focus:border-white/25! focus:ring-0!">
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="terminal-target-list max-h-96 overflow-y-auto p-2">
|
||||
@if ($isLoadingContainers)
|
||||
<div class="flex min-h-28 items-center justify-center">
|
||||
<div class="terminal-loading-label flex items-center gap-2">
|
||||
<x-loading class="size-4" />
|
||||
<span>Loading servers and containers…</span>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($servers->isEmpty())
|
||||
<div class="px-3 py-8 text-center">
|
||||
<div class="text-sm font-medium text-white/70">No terminal targets available</div>
|
||||
<div class="mt-1 text-xs text-white/45">Connect a reachable server and enable terminal access.</div>
|
||||
</div>
|
||||
@else
|
||||
<template x-for="target in filteredTargets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm text-white/70 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-show="target.type === 'server'"
|
||||
class="size-4 shrink-0 text-white/40" />
|
||||
<x-reicon name="layers" x-show="target.type === 'container'"
|
||||
class="size-4 shrink-0 text-white/40" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
|
||||
</button>
|
||||
</template>
|
||||
<div x-show="filteredTargets.length === 0"
|
||||
class="px-3 py-8 text-center text-sm text-white/50">
|
||||
No matching targets
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div data-terminal-session-canvas
|
||||
class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg p-3 sm:p-6"
|
||||
:data-console-theme="consoleTheme"
|
||||
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
|
||||
<header
|
||||
class="application-console-header flex h-[30px] shrink-0 items-center border-b border-white/[0.12] px-2.5 text-[11px] text-white select-none">
|
||||
class="terminal-session-toolbar absolute top-3 right-3 left-3 z-20 flex items-center gap-3 text-white select-none">
|
||||
<div class="relative flex min-w-0 flex-1 items-center gap-2"
|
||||
x-on:click.outside="targetOpen = false">
|
||||
@if ($isLoadingContainers)
|
||||
@@ -115,7 +187,8 @@
|
||||
</span>
|
||||
@else
|
||||
<button type="button"
|
||||
class="flex h-6 min-w-0 max-w-sm cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-white/[0.08]"
|
||||
x-cloak x-show="targetChosen"
|
||||
class="terminal-session-target-trigger flex h-8 min-w-0 max-w-sm cursor-pointer items-center gap-2 rounded-md px-2.5 text-left text-xs font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
x-on:click="targetOpen = !targetOpen" :aria-expanded="targetOpen"
|
||||
aria-label="Choose terminal target">
|
||||
<span class="min-w-0 truncate text-[11px] font-semibold text-white/80"
|
||||
@@ -128,7 +201,7 @@
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="targetOpen" x-transition.origin.top.left
|
||||
class="absolute top-7 left-0 z-50 w-72 overflow-hidden rounded-lg border border-white/[0.1] bg-[#111113] shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
class="terminal-target-picker absolute top-11 left-0 z-50 w-80 overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
|
||||
<div class="border-b border-white/[0.08] p-1.5">
|
||||
<div class="relative">
|
||||
<x-reicon name="search"
|
||||
@@ -138,7 +211,7 @@
|
||||
class="h-7! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2! pl-7! text-[11px]! text-white! shadow-none! placeholder:text-white/30 focus:border-accent! focus:ring-0!">
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-y-auto p-1">
|
||||
<div class="terminal-target-list max-h-72 overflow-y-auto p-1">
|
||||
<template x-for="target in filteredTargets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
@@ -164,57 +237,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden items-center gap-1.5 text-[10px] font-medium text-white/40"
|
||||
wire:loading.flex wire:target="selected_uuid,connectToContainer">
|
||||
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
|
||||
stroke-width="3" />
|
||||
<path class="opacity-75" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor"
|
||||
stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
Connecting
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="relative ml-auto shrink-0" x-on:click.outside="themeOpen = false">
|
||||
<button type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[10px] font-medium text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white/90"
|
||||
x-on:click="themeOpen = !themeOpen" aria-label="Choose terminal theme"
|
||||
:aria-expanded="themeOpen">
|
||||
<span class="size-2 rounded-full ring-1 ring-white/20"
|
||||
:style="{ backgroundColor: @js($consoleThemeAccents)[consoleTheme] }"></span>
|
||||
<span x-text="@js($consoleThemeNames)[consoleTheme]"></span>
|
||||
<svg class="size-2.5 text-white/35" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
|
||||
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
@foreach ($consoleThemes as $theme)
|
||||
<button type="button"
|
||||
class="flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
x-on:click="setTheme('{{ $theme['key'] }}')">
|
||||
<span class="h-3 w-5 rounded-full border border-white/10"
|
||||
style="background: {{ $theme['background'] }}"></span>
|
||||
<span class="flex-1">{{ $theme['name'] }}</span>
|
||||
<svg x-show="consoleTheme === '{{ $theme['key'] }}'" class="size-3 text-[#fcd452]"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
|
||||
:theme-accents="$consoleThemeAccents" />
|
||||
</header>
|
||||
|
||||
<div class="terminal-session-panel mt-8 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="application-console-block min-h-0 flex-1 overflow-hidden">
|
||||
@if ($isLoadingContainers)
|
||||
<div class="flex h-full min-h-0 items-center justify-center">
|
||||
<div class="flex items-center gap-2 text-[11px] text-white/45">
|
||||
<div class="terminal-loading-label flex items-center gap-2">
|
||||
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
|
||||
stroke-width="3" />
|
||||
@@ -231,9 +265,46 @@
|
||||
icon-name="browser-terminal" />
|
||||
</div>
|
||||
@else
|
||||
<div x-cloak x-show="!targetChosen" data-terminal-target-picker="launcher"
|
||||
class="absolute inset-0 z-20 flex items-start justify-start p-6 sm:p-10">
|
||||
<div class="terminal-target-picker w-full max-w-md overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
|
||||
<div class="border-b border-white/[0.08] p-2">
|
||||
<div class="px-1 pb-2">
|
||||
<div class="text-sm font-semibold text-white/80">Start a terminal session</div>
|
||||
<div class="mt-0.5 text-[11px] text-white/45">Choose a server or container</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-white/35" />
|
||||
<input x-model.debounce.100ms="targetSearch" type="search"
|
||||
placeholder="Filter targets…"
|
||||
class="h-8! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2! pl-7! text-[11px]! text-white! shadow-none! placeholder:text-white/30 focus:border-accent! focus:ring-0!">
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-y-auto p-1">
|
||||
<template x-for="target in filteredTargets" :key="target.value">
|
||||
<button type="button"
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
x-on:click="selectTarget(target)">
|
||||
<x-reicon name="servers" x-show="target.type === 'server'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<x-reicon name="layers" x-show="target.type === 'container'"
|
||||
class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<div x-show="filteredTargets.length === 0"
|
||||
class="px-2 py-5 text-center text-[11px] text-white/35">
|
||||
No matching targets
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<livewire:project.shared.terminal variant="application" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('uses the shared status summary in the primary application server card', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
|
||||
$applicationSection = str($view)->before('@else')->value();
|
||||
|
||||
expect($applicationSection)
|
||||
->toContain('<x-status-summary :status="$resource->status" />')
|
||||
->not->toContain('<x-status :resource="$resource"');
|
||||
});
|
||||
@@ -66,8 +66,8 @@ it('styles icon buttons with a visible hover state', function () {
|
||||
->toContain('dark:hover:bg-white/[0.07]');
|
||||
|
||||
expect($resourceCard)
|
||||
->toContain('class="icon-button shrink-0"')
|
||||
->toContain('title="Edit domains"');
|
||||
->toContain('class="icon-button"')
|
||||
->toContain('title="Manage domains"');
|
||||
});
|
||||
|
||||
it('exposes enable and disable public access methods on service index', function () {
|
||||
|
||||
@@ -86,11 +86,21 @@ test('helper popup stays within the visual viewport on mobile', function () {
|
||||
->toContain('window.visualViewport')
|
||||
->toContain('viewport?.offsetLeft')
|
||||
->toContain('viewport?.offsetTop')
|
||||
->toContain('max-width: ${availableWidth}px;')
|
||||
->toContain('max-width: ${popupWidth}px;')
|
||||
->toContain('max-height: ${availableHeight}px;')
|
||||
->toContain('overflow-y: auto;');
|
||||
});
|
||||
|
||||
test('helper popup width is capped while allowing content to wrap vertically', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
expect($helper)
|
||||
->toContain('const maxPopupWidth = 320;')
|
||||
->toContain('const popupWidth = Math.min(maxPopupWidth, availableWidth);')
|
||||
->toContain('max-width: ${popupWidth}px;')
|
||||
->toContain('whitespace-normal');
|
||||
});
|
||||
|
||||
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
|
||||
|
||||
@@ -107,11 +107,12 @@ it('renders volume rows without nesting Livewire Show components', function () {
|
||||
it('batches volume backup meta and exposes forms for every volume', function () {
|
||||
[$application, , $team] = createPerfApplicationWithVolumes(5);
|
||||
|
||||
foreach ($application->persistentStorages as $storage) {
|
||||
foreach ($application->persistentStorages as $index => $storage) {
|
||||
$storage->scheduledBackups()->create([
|
||||
'team_id' => $team->id,
|
||||
'frequency' => 'daily',
|
||||
'enabled' => true,
|
||||
'save_s3' => $index === 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -120,7 +121,10 @@ it('batches volume backup meta and exposes forms for every volume', function ()
|
||||
expect($component->get('volumeBackupMeta'))->toHaveCount(5)
|
||||
->and($component->get('forms'))->toHaveCount(5)
|
||||
->and($component->html())->toContain('volumes-col-backup')
|
||||
->not->toContain('table-badge table-badge-success');
|
||||
->toContain('Backups are saved to S3')
|
||||
->toContain('Backups are stored locally only');
|
||||
|
||||
expect($component->get("volumeBackupMeta.{$application->persistentStorages->first()->id}.s3"))->toBeTrue();
|
||||
|
||||
foreach ($component->get('volumeBackupMeta') as $meta) {
|
||||
expect($meta['enabled'])->toBeTrue()
|
||||
|
||||
@@ -1,5 +1,161 @@
|
||||
<?php
|
||||
|
||||
it('renders the resource terminal shell while containers are discovered', function () {
|
||||
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toContain('public bool $containersLoaded = false;')
|
||||
->and($terminalView)
|
||||
->toContain('wire:init="loadContainers"')
|
||||
->not->toContain('@if (! $containersLoaded)')
|
||||
->not->toContain('Loading terminal containers…')
|
||||
->toContain(':auto-start="$type === \'server\' || ! $containersLoaded || $containers->count() === 1"')
|
||||
->not->toContain('Loading targets…')
|
||||
->not->toContain('<x-loading text="Loading containers" />');
|
||||
});
|
||||
|
||||
it('starts a single discovered resource container without waiting for a missed browser event', function () {
|
||||
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toMatch('/if \(\$this->containers->count\(\) === 1\) \{\s*\$this->selected_container = [^;]+;\s*\$this->connectToContainer\(\);/s')
|
||||
->and($terminalView)
|
||||
->not->toContain('x-on:containers-loaded.window');
|
||||
});
|
||||
|
||||
it('integrates automatic terminal startup into the terminal console', function () {
|
||||
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$commandView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toContain('public bool $autoStart = false;')
|
||||
->and($terminalView)
|
||||
->toContain('data-auto-start="{{ $autoStart ? \'true\' : \'false\' }}"')
|
||||
->toContain("starting ? 'connecting…'")
|
||||
->and($commandView)
|
||||
->toContain(':auto-start="$type === \'server\' || ! $containersLoaded || $containers->count() === 1"')
|
||||
->not->toContain('terminalLoading')
|
||||
->not->toContain('Starting terminal');
|
||||
});
|
||||
|
||||
it('resynchronizes the themed canvas whenever a terminal enters a loading state', function () {
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
foreach ([$resourceView, $globalView] as $view) {
|
||||
expect($view)
|
||||
->toContain('x-on:terminal-starting.window="syncTheme()"')
|
||||
->toContain('syncTheme() {')
|
||||
->toContain("this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the xterm mount visible while the terminal connection starts', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toMatch('/<div id="terminal"[^>]*wire:ignore/s')
|
||||
->not->toMatch('/<div id="terminal"[^>]*x-show="terminalActive"/s');
|
||||
});
|
||||
|
||||
it('shows the initial resource container launcher and keeps the header picker for switching', function () {
|
||||
$commandView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($commandView)
|
||||
->toContain('targetChosen: @js($selected_container !== \'default\')')
|
||||
->toContain('data-terminal-target-picker="launcher"')
|
||||
->toContain('x-show="!targetChosen"')
|
||||
->toContain('x-show="targetChosen"')
|
||||
->toContain('this.targetChosen = true;');
|
||||
});
|
||||
|
||||
it('uses a larger high-contrast label for every terminal loading phase', function () {
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect(substr_count($resourceView.$terminalView.$globalView, 'terminal-loading-label'))
|
||||
->toBeGreaterThanOrEqual(3)
|
||||
->and($styles)
|
||||
->toContain('.terminal-loading-label')
|
||||
->toContain('font-size: 0.875rem;')
|
||||
->toContain('color: rgb(255 255 255 / 0.75);')
|
||||
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-loading-label');
|
||||
});
|
||||
|
||||
it('styles centered terminal target pickers from the selected console theme', function () {
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect(substr_count($resourceView.$globalView, 'terminal-target-picker'))
|
||||
->toBeGreaterThanOrEqual(2)
|
||||
->and($styles)
|
||||
->toContain('.terminal-target-picker')
|
||||
->toContain('.application-console-shell[data-console-theme="system"] .terminal-target-picker')
|
||||
->toContain('background: rgb(0 0 0 / 0.18);')
|
||||
->toContain('background: #fff;')
|
||||
->toContain('color: #52525b;');
|
||||
});
|
||||
|
||||
it('presents initial terminal targets as a top-aligned launcher instead of a centered dialog', function () {
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
expect($resourceView.$globalView)
|
||||
->toContain('data-terminal-target-picker="launcher"')
|
||||
->toContain('items-start justify-start')
|
||||
->toContain('Start a terminal session')
|
||||
->not->toContain('data-terminal-target-picker="center"');
|
||||
});
|
||||
|
||||
it('shows connection progress in the terminal body instead of the header', function () {
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($resourceView.$globalView)
|
||||
->toContain("new CustomEvent('terminal-starting')")
|
||||
->not->toContain('wire:loading.flex wire:target="selected_container,connectToContainer"')
|
||||
->not->toContain('wire:loading.flex wire:target="selected_uuid,connectToContainer"')
|
||||
->and($terminalView)
|
||||
->toContain("x-on:terminal-starting.window=\"starting = true; setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')\"")
|
||||
->toContain('data-auto-start="{{ $autoStart ? \'true\' : \'false\' }}"')
|
||||
->toContain("starting ? 'connecting…'")
|
||||
->and($terminalClient)
|
||||
->toContain('starting: false')
|
||||
->toContain("this.starting = this.\$el.dataset.autoStart === 'true';");
|
||||
});
|
||||
|
||||
it('uses the redesigned terminal canvas and controls on resource terminal pages', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('data-terminal-session-canvas')
|
||||
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
|
||||
->toContain('terminal-session-panel mt-8')
|
||||
->toContain('<x-terminal.theme-selector')
|
||||
->not->toContain('application-console-header flex h-[30px]');
|
||||
});
|
||||
|
||||
it('uses a readable theme-aware terminal session expiry label', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('terminal-session-expiry')
|
||||
->and($styles)
|
||||
->toContain('.terminal-session-expiry')
|
||||
->toContain('font-size: 0.75rem;')
|
||||
->toContain('color: rgb(255 255 255 / 0.6);')
|
||||
->toContain('.terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry');
|
||||
});
|
||||
|
||||
it('copies the realtime terminal utilities into the container image', function () {
|
||||
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
|
||||
|
||||
@@ -201,15 +357,18 @@ it('resizes after toggling the mobile terminal toolbar', function () {
|
||||
|
||||
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('terminal-fullscreen-shell fixed inset-0')
|
||||
->toContain('z-[100000]')
|
||||
->toContain('h-[100dvh]')
|
||||
->toContain('w-screen')
|
||||
->toContain('max-w-none')
|
||||
->toContain('overflow-hidden')
|
||||
->toContain(':data-console-theme="fullscreen ? selectedTheme : null"');
|
||||
->toContain(':data-console-theme="fullscreen ? selectedTheme : null"')
|
||||
->not->toContain('h-[100dvh]')
|
||||
->and($styles)
|
||||
->toContain('height: auto !important;');
|
||||
});
|
||||
|
||||
it('constrains normal terminal height after leaving fullscreen', function () {
|
||||
@@ -229,17 +388,25 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function
|
||||
->and($appCss)
|
||||
->toContain('.terminal-fullscreen-btn')
|
||||
->toContain('width: 1.75rem')
|
||||
->toContain('height: 1.75rem');
|
||||
->toContain('height: 1.75rem')
|
||||
->toContain('color: var(--terminal-scrollbar')
|
||||
->toContain('background: transparent;')
|
||||
->toContain('color-mix(in srgb, var(--terminal-scrollbar');
|
||||
});
|
||||
|
||||
it('does not apply backdrop-filter on the console block itself so fixed fullscreen can escape', function () {
|
||||
it('lets the selected theme show through the active terminal panel', function () {
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($appCss)
|
||||
->toContain('.application-console-block::before')
|
||||
->toContain('position:fixed descendants')
|
||||
->toMatch('/\.application-console-block::before\s*\{[^}]*backdrop-filter/s')
|
||||
->not->toMatch('/\.application-console-block\s*\{[^}]*\bbackdrop-filter\s*:/s');
|
||||
->toMatch('/\.terminal-session-panel\s*\{[^}]*background:\s*transparent;/s')
|
||||
->toMatch('/\.terminal-session-panel\s*\{[^}]*border:\s*0;/s')
|
||||
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s')
|
||||
->not->toContain('.application-console-block::before')
|
||||
->not->toMatch('/\.application-console-block\s*\{[^}]*\bbackdrop-filter\s*:/s')
|
||||
->and($terminalView)
|
||||
->toContain('items-center justify-center bg-transparent')
|
||||
->not->toContain('items-center justify-center bg-black/35');
|
||||
});
|
||||
|
||||
it('keeps the terminal in the Livewire tree and unlocks ancestor stacking for fullscreen', function () {
|
||||
|
||||
@@ -44,3 +44,13 @@ it('aligns compose resource columns and uses icon actions', function () {
|
||||
->not->toContain('>Settings</a>')
|
||||
->not->toContain('>Backups</a>');
|
||||
});
|
||||
|
||||
it('distinguishes domain management from resource settings', function () {
|
||||
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
|
||||
expect($resourceCard)
|
||||
->toContain('title="Manage domains" aria-label="Manage domains"')
|
||||
->toContain('<x-reicon name="globe" class="size-4" />')
|
||||
->toContain('title="Resource settings" aria-label="Resource settings"')
|
||||
->not->toContain('title="Edit domains" aria-label="Edit domains"');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,126 @@
|
||||
<?php
|
||||
|
||||
it('shows the initial terminal target launcher and keeps the header picker for switching', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('targetChosen: @js($selected_uuid !== \'default\')')
|
||||
->toContain('data-terminal-target-picker="launcher"')
|
||||
->toContain('x-show="!targetChosen"')
|
||||
->toContain('x-show="targetChosen"')
|
||||
->toContain('this.targetChosen = true;');
|
||||
});
|
||||
|
||||
it('shows a centered themed target canvas before loading xterm', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain("@if (\$selected_uuid === 'default')")
|
||||
->toContain('data-terminal-target-picker="page"')
|
||||
->toContain('data-terminal-target-canvas')
|
||||
->toContain('items-center justify-center')
|
||||
->toContain(':data-console-theme="consoleTheme"')
|
||||
->toContain("@else\n <div data-terminal-session-canvas");
|
||||
});
|
||||
|
||||
it('loads targets inside the themed session picker with an accent scrollbar', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Finding available servers and containers…')
|
||||
->toContain('terminal-target-list')
|
||||
->toContain('Loading servers and containers…')
|
||||
->toContain("'--terminal-scrollbar': themeAccents[consoleTheme]")
|
||||
->and($styles)
|
||||
->toContain('.terminal-target-list')
|
||||
->toContain('var(--terminal-scrollbar')
|
||||
->toContain('.terminal-target-list::-webkit-scrollbar-thumb');
|
||||
});
|
||||
|
||||
it('uses the same padded themed canvas for the active terminal session', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('data-terminal-session-canvas')
|
||||
->toContain('p-3 sm:p-6')
|
||||
->toContain('terminal-session-panel mt-8')
|
||||
->and($styles)
|
||||
->toContain('.terminal-session-panel')
|
||||
->toContain('border-radius: 0.75rem;')
|
||||
->toMatch('/\.terminal-session-panel\s*\{[^}]*border:\s*0;/s')
|
||||
->toMatch('/\.terminal-session-panel\s*\{[^}]*background:\s*transparent;/s')
|
||||
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s');
|
||||
});
|
||||
|
||||
it('uses floating rounded controls instead of the legacy terminal header bar', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('terminal-session-toolbar')
|
||||
->toContain('terminal-session-target-trigger')
|
||||
->toContain('<x-terminal.theme-selector')
|
||||
->not->toContain('application-console-header flex h-[30px]');
|
||||
});
|
||||
|
||||
it('keeps the theme selector identical before and during a terminal session', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
|
||||
|
||||
expect(substr_count($view, '<x-terminal.theme-selector'))
|
||||
->toBe(2)
|
||||
->and($selector)
|
||||
->toContain('terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-medium')
|
||||
->and($view)
|
||||
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
|
||||
->not->toContain('terminal-theme-trigger');
|
||||
});
|
||||
|
||||
it('uses the same borderless control style for target and theme selectors', function () {
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($globalView.$resourceView)
|
||||
->not->toContain('terminal-session-target-trigger flex h-9')
|
||||
->not->toContain('terminal-session-target-trigger flex h-8 min-w-0 max-w-sm cursor-pointer items-center gap-2 rounded-md border')
|
||||
->toContain('terminal-session-target-trigger flex h-8')
|
||||
->toContain('rounded-md px-2.5');
|
||||
});
|
||||
|
||||
it('reuses one chevron theme selector before and during terminal sessions', function () {
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
|
||||
|
||||
expect(substr_count($globalView.$resourceView, '<x-terminal.theme-selector'))
|
||||
->toBe(3)
|
||||
->and($selector)
|
||||
->toContain('terminal-theme-trigger')
|
||||
->toContain('viewBox="0 0 12 12"')
|
||||
->toContain('m3.5 4.75 2.5 2.5 2.5-2.5');
|
||||
});
|
||||
|
||||
it('keeps the pre-session theme selector out of the centered picker layout', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="absolute top-3 right-3 z-20"')
|
||||
->toContain('<x-terminal.theme-selector');
|
||||
});
|
||||
|
||||
it('updates the owning terminal theme directly so its label and canvas stay synchronized', function () {
|
||||
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
|
||||
|
||||
expect($globalView.$resourceView)
|
||||
->toContain('setTheme(theme)')
|
||||
->and($selector)
|
||||
->toContain('@click="setTheme(')
|
||||
->not->toContain("\$dispatch('terminal-theme-selected'");
|
||||
});
|
||||
|
||||
it('places the terminal helper beside the page title instead of under the subtitle', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
@@ -57,7 +178,7 @@ it('defaults to a system console theme that follows the page color mode', functi
|
||||
expect($terminalClient)
|
||||
->toContain("'system': createSystemTerminalTheme()")
|
||||
->toContain('new MutationObserver')
|
||||
->toContain("attributeFilter: ['class', 'data-theme']")
|
||||
->toContain("attributeFilter: ['class', 'data-theme', 'style']")
|
||||
->and($appCss)
|
||||
->toContain('[data-console-theme="system"]')
|
||||
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]')
|
||||
@@ -69,16 +190,19 @@ it('defaults to a system console theme that follows the page color mode', functi
|
||||
it('uses the page color mode for the console theme selector', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
|
||||
|
||||
foreach ([$terminalView, $consoleView] as $view) {
|
||||
expect($view)
|
||||
->toContain('console-theme-selector')
|
||||
->toContain('border-neutral-200 bg-white')
|
||||
->toContain('dark:bg-[#111113]')
|
||||
->toContain('text-neutral-600 transition-colors')
|
||||
->toContain('dark:text-white/65');
|
||||
expect($view)->toContain('<x-terminal.theme-selector');
|
||||
}
|
||||
|
||||
expect($selector)
|
||||
->toContain('console-theme-selector')
|
||||
->toContain('border-neutral-200 bg-white')
|
||||
->toContain('dark:bg-[#111113]')
|
||||
->toContain('text-neutral-600 transition-colors')
|
||||
->toContain('dark:text-white/65');
|
||||
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($appCss)
|
||||
@@ -93,7 +217,7 @@ it('shows terminal unavailable without the terminal shell wrapper', function ()
|
||||
|
||||
$unavailableBranch = str($consoleView)
|
||||
->after('@if ($consoleUnavailable)')
|
||||
->before('@else')
|
||||
->before("@else\n <section class=\"mt-8 mb-0!")
|
||||
->toString();
|
||||
|
||||
expect($consoleView)
|
||||
|
||||
@@ -56,6 +56,21 @@ it('keeps the volume backup script inside the Livewire root element', function (
|
||||
expect(strrpos($view, '@endscript'))->toBeLessThan(strrpos($view, '</div>'));
|
||||
});
|
||||
|
||||
it('shows the S3 configuration state in application and service backup tables', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/backup/index.blade.php'),
|
||||
resource_path('views/livewire/project/service/volume-backup/index.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))
|
||||
->toContain('<span>S3</span>')
|
||||
->toContain("'Configured'")
|
||||
->toContain("'Unavailable'")
|
||||
->toContain("'Not set'");
|
||||
}
|
||||
});
|
||||
|
||||
it('targets named volumes and application directory mounts through one backup relation', function () {
|
||||
$team = Team::factory()->create();
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
|
||||
Reference in New Issue
Block a user