feat(ui): improve terminal mobile UX and server status feedback

Add keyboard-aware terminal controls, refine terminal layouts and navigation, surface proxy and Sentinel health warnings, and make avatar uploads update only after successful persistence.
This commit is contained in:
Andras Bacsai 2026-08-11 09:33:11 +02:00
parent 32bf1860d4
commit 3f47d41880
20 changed files with 367 additions and 105 deletions

View file

@ -9,6 +9,7 @@ APP_PORT=8000
APP_DEBUG=true
SSH_MUX_ENABLED=true
COOLIFY_CONTAINER_ROLE=all
DEV_SENTINEL_URL=
# PostgreSQL Database Configuration
DB_DATABASE=coolify

View file

@ -38,7 +38,7 @@ class Index extends Component
public $avatar;
public function uploadAvatar(AvatarStorageService $avatarStorage): void
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
{
try {
$this->validate([
@ -49,8 +49,12 @@ public function uploadAvatar(AvatarStorageService $avatarStorage): void
$this->reset('avatar');
$this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp]));
$this->dispatch('success', 'Profile picture updated.');
return true;
} catch (\Throwable $e) {
handleError($e, $this);
return false;
}
}

View file

@ -99,6 +99,7 @@
],
'sentinel' => [
'dev_url' => env('DEV_SENTINEL_URL'),
// How often (seconds) PushServerUpdateJob is force-dispatched even when
// the container state hash is unchanged. Keeps exited-detection and
// storage checks from going stale without writing every resource row on

View file

@ -16,6 +16,14 @@ public function run()
if (str($server->settings->sentinel_token)->isEmpty()) {
$server->settings->generateSentinelToken(ignoreEvent: true);
}
$developmentUrl = isDev() ? config('constants.sentinel.dev_url') : null;
if (filled($developmentUrl)) {
$server->settings->sentinel_custom_url = $developmentUrl;
$server->settings->saveQuietly();
continue;
}
if (str($server->settings->sentinel_custom_url)->isEmpty()) {
$url = $server->settings->generateSentinelUrl(ignoreEvent: true);
if (str($url)->isEmpty()) {

View file

@ -78,7 +78,13 @@ @theme {
@layer components {
.terminal-mobile-key {
@apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
@apply min-h-8 shrink-0 rounded-full border bg-transparent px-3 py-1 text-sm font-medium text-neutral-300 active:text-white;
border-color: color-mix(in srgb, var(--terminal-scrollbar, #fff) 24%, transparent);
}
.terminal-key-row {
border: 1px solid color-mix(in srgb, var(--terminal-scrollbar, #fff) 22%, transparent);
background: transparent;
}
/* Active state is a solid fill only (no accent rail / border). */
@ -714,17 +720,6 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina
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);
@ -2457,6 +2452,16 @@ .deployment-table-grid {
grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr);
}
@media (min-width: 1024px) {
.deployment-table-scroll {
overflow-x: visible;
}
.deployment-table-grid {
min-width: 0;
}
}
.dashboard-deployment-table-grid {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem;
}

View file

@ -196,6 +196,14 @@ export function initializeTerminalComponent() {
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
terminalModifier: null,
keyboardInset: 0,
keyboardAnchorTop: 0,
keyboardViewportHeight: 0,
keyboardViewportWidth: 0,
keyboardInsetSettleTimeout: null,
updateKeyboardInset: null,
syncKeyboardInset: null,
// Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting).
fullscreenAncestorPatches: null,
pageScrollLocked: false,
@ -212,6 +220,53 @@ export function initializeTerminalComponent() {
init() {
this.starting = this.$el.dataset.autoStart === 'true';
this.updateKeyboardInset = () => {
const viewport = window.visualViewport;
const viewportWidth = viewport?.width ?? window.innerWidth;
const layoutHeight = Math.max(
window.innerHeight,
document.documentElement.clientHeight,
viewport ? viewport.height + viewport.offsetTop : 0,
);
// Track the tallest viewport seen at this width — an open software
// keyboard shrinks the visual viewport well below it. A large width
// change (rotation) resets the baseline.
if (Math.abs(this.keyboardViewportWidth - viewportWidth) > 80) {
this.keyboardViewportHeight = layoutHeight;
} else {
this.keyboardViewportHeight = Math.max(this.keyboardViewportHeight, layoutHeight);
}
this.keyboardViewportWidth = viewportWidth;
const visualBottom = viewport ? viewport.height + viewport.offsetTop : layoutHeight;
this.keyboardInset = window.innerWidth < 640 && viewport
? Math.max(0, Math.round(this.keyboardViewportHeight - visualBottom))
: 0;
// position:fixed resolves `top` against the layout viewport and
// visualViewport.offsetTop is relative to it, so offsetTop + height
// is the exact bottom edge of the visible area — a toolbar pinned at
// this anchor rides on top of the keyboard no matter how the browser
// reports keyboard geometry (iOS overlay or Android layout resize).
this.keyboardAnchorTop = Math.round(visualBottom);
this.syncFullscreenShellWithKeyboard(viewport);
if (this.fullscreen) {
this.$nextTick(() => this.resizeTerminal());
}
};
this.syncKeyboardInset = () => {
// iOS fires viewport events mid keyboard animation — re-measure once
// the keyboard settles.
this.updateKeyboardInset();
clearTimeout(this.keyboardInsetSettleTimeout);
this.keyboardInsetSettleTimeout = setTimeout(this.updateKeyboardInset, 250);
};
this.updateKeyboardInset();
window.visualViewport?.addEventListener('resize', this.syncKeyboardInset);
window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset);
window.addEventListener('resize', this.syncKeyboardInset);
this.themeObserver = new MutationObserver(() => {
if (this.selectedTheme === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
@ -257,7 +312,7 @@ export function initializeTerminalComponent() {
}
this.$nextTick(() => {
if (active) {
this.$refs.terminalWrapper.style.display = 'block';
this.$refs.terminalWrapper.style.removeProperty('display');
this.resizeTerminal();
// Start observing terminal wrapper for resize changes
@ -266,8 +321,11 @@ export function initializeTerminalComponent() {
}
} else {
const terminalElement = document.getElementById('terminal');
this.$refs.terminalWrapper.style.display =
terminalElement?.dataset.terminalStyle === 'application' ? 'block' : 'none';
if (terminalElement?.dataset.terminalStyle === 'application') {
this.$refs.terminalWrapper.style.removeProperty('display');
} else {
this.$refs.terminalWrapper.style.display = 'none';
}
// Stop observing when terminal is inactive
if (this.resizeObserver) {
@ -305,6 +363,10 @@ export function initializeTerminalComponent() {
},
cleanup() {
window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset);
window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset);
window.removeEventListener('resize', this.syncKeyboardInset);
clearTimeout(this.keyboardInsetSettleTimeout);
this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers();
this.connectionState = 'disconnected';
@ -848,6 +910,10 @@ export function initializeTerminalComponent() {
destroy() {
this.themeObserver?.disconnect();
window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset);
window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset);
window.removeEventListener('resize', this.syncKeyboardInset);
clearTimeout(this.keyboardInsetSettleTimeout);
},
@ -856,7 +922,6 @@ export function initializeTerminalComponent() {
return;
}
this.term.focus();
this.sendMessage({ message: data });
},
@ -868,14 +933,35 @@ export function initializeTerminalComponent() {
arrowLeft: '\x1b[D',
tab: '\t',
escape: '\x1b',
ctrlC: '\x03'
ctrlC: '\x03',
ctrlBackslash: '\x1c',
ctrlS: '\x13',
ctrlZ: '\x1a'
};
if (terminalSequences[sequence]) {
this.terminalModifier = null;
this.sendTerminalInput(terminalSequences[sequence]);
}
},
toggleTerminalModifier(modifier) {
this.terminalModifier = this.terminalModifier === modifier ? null : modifier;
},
sendTerminalKey(key) {
let input = key;
if (this.terminalModifier === 'ctrl') {
input = String.fromCharCode(key.toUpperCase().charCodeAt(0) & 31);
} else if (this.terminalModifier === 'alt') {
input = `\x1b${key}`;
}
this.terminalModifier = null;
this.sendTerminalInput(input);
},
async pasteFromClipboard() {
if (!navigator.clipboard?.readText) {
this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.');
@ -979,6 +1065,29 @@ export function initializeTerminalComponent() {
this.sendMessage({ checkActive: 'force' });
},
/**
* While the software keyboard is open, shrink the fullscreen shell to the
* visual viewport so xterm rows and the mobile key row stay visible above
* the keyboard. Inline !important is required to outrank the stylesheet's
* `inset: 0 !important` / `height: auto !important` fullscreen rules.
*/
syncFullscreenShellWithKeyboard(viewport) {
const wrapper = this.$refs.terminalWrapper;
if (!wrapper) {
return;
}
if (this.fullscreen && viewport && this.keyboardInset > 0) {
wrapper.style.setProperty('top', `${Math.round(viewport.offsetTop)}px`, 'important');
wrapper.style.setProperty('height', `${Math.round(viewport.height)}px`, 'important');
wrapper.style.setProperty('bottom', 'auto', 'important');
} else {
wrapper.style.removeProperty('top');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('bottom');
}
},
makeFullscreen() {
if (this.fullscreen) {
this.exitFullscreen();
@ -1012,6 +1121,7 @@ export function initializeTerminalComponent() {
this.fullscreen = true;
document.documentElement.classList.add('terminal-is-fullscreen');
document.body.classList.add('terminal-is-fullscreen');
this.updateKeyboardInset?.();
this.scheduleTerminalResize();
},
@ -1032,6 +1142,7 @@ export function initializeTerminalComponent() {
// Recover from older portal builds that left the terminal on <body>.
this.salvageStrayFullscreenNodes();
this.updateKeyboardInset?.();
this.scheduleTerminalResize();
},

View file

@ -328,7 +328,8 @@ class="mt-4 mb-1.5 block text-[12px] font-medium text-neutral-700 dark:text-fg-d
class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
modalOpen = false;
$nextTick(() => $wire.dispatch(dispatchEventType, dispatchEventMessage));
}
if (confirmWithPassword && !skipPasswordConfirmation) {
step++;

View file

@ -83,7 +83,7 @@
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'active' => $activeMenu === 'terminal',
'icon' => 'browser-terminal',
'group' => 'Operations',
'navigate' => false,

View file

@ -169,11 +169,15 @@ class="button button-highlighted">
<div class="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($dashboardServers as $server)
@php
$proxyNeedsAttention = $server->proxySet() && $server->proxy->status !== 'running';
$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive();
[$serverStatus, $serverStatusType] = match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
};
@endphp

View file

@ -52,9 +52,25 @@
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8);
});
this.preview = URL.createObjectURL(blob);
const previewUrl = URL.createObjectURL(blob);
const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
this.$wire.upload('avatar', compressed, () => this.processing = false, () => {
this.$wire.upload('avatar', compressed, async () => {
try {
const uploaded = await this.$wire.uploadAvatar();
if (uploaded) {
if (this.preview) URL.revokeObjectURL(this.preview);
this.preview = previewUrl;
} else {
URL.revokeObjectURL(previewUrl);
}
} catch (error) {
URL.revokeObjectURL(previewUrl);
this.uploadError = 'The image could not be uploaded.';
} finally {
this.processing = false;
}
}, () => {
URL.revokeObjectURL(previewUrl);
this.processing = false;
this.uploadError = 'The image could not be uploaded.';
});
@ -84,22 +100,22 @@ class="h-full w-full object-cover">
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3">
<input type="file" x-on:change="prepareAvatar($event)" accept="image/jpeg,image/png,image/webp"
class="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-200 file:px-3 file:py-2 file:text-xs file:font-medium file:text-neutral-800 hover:file:bg-neutral-300 dark:text-fg-dim dark:file:bg-white/[0.08] dark:file:text-fg dark:hover:file:bg-white/[0.12]">
<div class="flex flex-wrap items-center gap-2">
<input x-ref="avatarInput" type="file" x-on:change="prepareAvatar($event)"
accept="image/jpeg,image/png,image/webp" class="hidden">
<x-forms.button type="button" x-on:click="$refs.avatarInput.click()"
x-bind:disabled="processing">
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
</x-forms.button>
@if (auth()->user()->avatar_path)
<x-forms.button type="button" wire:click="removeAvatar" x-bind:disabled="processing"
isError>Remove</x-forms.button>
@endif
</div>
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('avatar')
<p class="text-xs text-red-500">{{ $message }}</p>
@enderror
<div class="flex flex-wrap gap-2">
<x-forms.button type="button" wire:click="uploadAvatar" wire:loading.attr="disabled"
wire:target="avatar,uploadAvatar" x-bind:disabled="processing || !preview" isHighlighted>
<span wire:loading.remove wire:target="uploadAvatar">Upload picture</span>
<span wire:loading wire:target="uploadAvatar">Compressing…</span>
</x-forms.button>
@if (auth()->user()->avatar_path)
<x-forms.button type="button" wire:click="removeAvatar" isError>Remove</x-forms.button>
@endif
</div>
</div>
</div>
</section>

View file

@ -60,10 +60,6 @@ class="size-3 animate-spin" viewBox="0 0 24 24" fill="none">
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="terminal-session-expiry pointer-events-none absolute right-3 bottom-2 z-20 font-mono"
x-text="terminalSessionRemainingLabel()">
</div>
@else
<div x-show="terminalActive" x-cloak class="mb-2 flex shrink-0 justify-start">
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
@ -74,41 +70,35 @@ class="terminal-session-expiry pointer-events-none absolute right-3 bottom-2 z-2
<div id="terminal" wire:ignore data-terminal-style="{{ $isApplicationConsole ? 'application' : 'default' }}"
:class="fullscreen
? (mobileToolbarCollapsed
? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'
: 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24')
? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'
: @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')">
</div>
<div x-show="terminalActive" x-cloak
:class="fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2' : 'relative mt-2 shrink-0'"
class="sm:hidden" data-terminal-mobile-toolbar>
<div
class="mx-auto max-w-3xl rounded-lg border border-white/10 bg-black/90 p-1.5 text-white shadow-lg backdrop-blur">
<div class="flex items-center justify-between gap-2">
<span class="px-2 text-[11px] font-medium uppercase tracking-wide text-neutral-400">Terminal keys</span>
<button type="button"
class="rounded px-2 py-1 text-xs text-neutral-300 hover:bg-white/10 hover:text-white"
x-on:click="mobileToolbarCollapsed = !mobileToolbarCollapsed; $nextTick(() => resizeTerminal())"
x-text="mobileToolbarCollapsed ? 'Show' : 'Hide'"
aria-label="Toggle mobile terminal toolbar"></button>
</div>
<div x-show="!mobileToolbarCollapsed" class="mt-1 grid grid-cols-6 gap-1">
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowUp')"
aria-label="Previous command"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowDown')"
aria-label="Next command"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowLeft')"
aria-label="Move cursor left"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowRight')"
aria-label="Move cursor right"></button>
<button type="button" class="terminal-mobile-key"
x-on:click="sendTerminalControl('tab')">Tab</button>
<button type="button" class="terminal-mobile-key"
x-on:click="sendTerminalControl('escape')">Esc</button>
</div>
:class="fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')"
:style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : ''"
data-terminal-mobile-toolbar>
<div class="terminal-key-row mx-auto flex max-w-3xl gap-1.5 overflow-x-auto whitespace-nowrap rounded-lg px-2 py-1.5 text-white [scrollbar-width:thin]">
<button type="button" class="terminal-mobile-key" x-on:click="pasteFromClipboard()">paste</button>
<button type="button" class="terminal-mobile-key" x-on:click="copyTerminalSelection()">copy</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('escape')">ESC</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('tab')">tab</button>
<button type="button" class="terminal-mobile-key"
:class="terminalModifier === 'ctrl' ? 'border-white/35 bg-white/20 text-white' : ''"
x-on:click="toggleTerminalModifier('ctrl')">ctrl</button>
<button type="button" class="terminal-mobile-key"
:class="terminalModifier === 'alt' ? 'border-white/35 bg-white/20 text-white' : ''"
x-on:click="toggleTerminalModifier('alt')">alt</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('/')">/</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('|')">|</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('~')">~</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('-')">-</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlC')">^C</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlBackslash')">^\</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlS')">^S</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlZ')">^Z</button>
</div>
</div>
@ -124,7 +114,7 @@ class="terminal-fullscreen-btn fixed top-3 right-3 z-[100001]"
<button type="button" title="Fullscreen" x-cloak x-show="!fullscreen && terminalActive"
@class([
'terminal-fullscreen-btn absolute z-20',
'right-2 top-2 opacity-0 group-hover/terminal:opacity-100 focus-visible:opacity-100' => $isApplicationConsole,
'right-2 top-2 opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100' => $isApplicationConsole,
'right-5 top-6' => !$isApplicationConsole,
])
x-on:click="makeFullscreen">

View file

@ -33,18 +33,22 @@ class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
&& $server->settings->is_usable
&& ! $server->settings->force_disabled
&& ! $isTransferredAway;
$proxyNeedsAttention = $isReady && $server->proxySet() && $server->proxy->status !== 'running';
$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive();
$status = match (true) {
$isTransferredAway => 'Transferred away',
$server->settings->force_disabled => 'Disabled',
$proxyNeedsAttention || $sentinelNeedsAttention => 'Attention required',
$isReady => 'Ready',
default => 'Validation required',
};
$statusType = match (true) {
$proxyNeedsAttention || $sentinelNeedsAttention => 'warning',
$isReady => 'success',
$isTransferredAway || $server->settings->force_disabled => 'error',
default => 'warning',
default => 'error',
};
return [

View file

@ -63,7 +63,7 @@
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'active' => $currentRoute === 'server.command',
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],

View file

@ -187,6 +187,8 @@
->and($appCss)
->toContain(".deployment-table-scroll {\n overflow-x: auto;")
->toContain(".deployment-table-grid {\n min-width: 59rem;")
->toContain("@media (min-width: 1024px) {\n .deployment-table-scroll {\n overflow-x: visible;")
->toContain(".deployment-table-grid {\n min-width: 0;")
->not->toContain('.deployment-table-grid > :nth-child')
->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;");
});

View file

@ -0,0 +1,9 @@
<?php
test('confirmation modal closes before dispatching an event that can open another modal', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
expect($modal)->toMatch(
'/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s'
);
});

View file

@ -104,14 +104,21 @@
->assertNotFound();
});
it('renders the profile upload and user menu avatar', function () {
it('automatically uploads a selected profile picture and keeps the current avatar until it succeeds', function () {
$profile = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($profile)
->toContain("this.\$wire.upload('avatar', compressed")
->toContain('await this.$wire.uploadAvatar()')
->toContain('if (uploaded)')
->toContain('canvas.toBlob')
->toContain('wire:click="uploadAvatar"')
->toContain('x-ref="avatarInput"')
->toContain('class="hidden"')
->toContain("processing ? 'Uploading…' : 'Browse…'")
->not->toContain('wire:click="uploadAvatar"')
->not->toContain('Upload picture')
->not->toContain('type="file" x-on:change')
->and($menu)
->toContain("route('profile.avatar',");
});

View file

@ -186,17 +186,11 @@
->not->toContain('application-console-header flex h-[30px]');
});
it('uses a readable theme-aware terminal session expiry label', function () {
it('does not overlay the session expiry label on the application terminal', 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');
->not->toContain('terminal-session-expiry');
});
it('copies the realtime terminal utilities into the container image', function () {
@ -329,27 +323,52 @@
->not->toContain("this.term.reset();\n this.term.clear();");
});
it('renders a compact mobile terminal toolbar with shell control keys', function () {
it('renders a horizontally scrollable mobile terminal key row', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('Terminal keys')
->toContain('sm:hidden')
->toContain("sendTerminalControl('arrowUp')")
->toContain("sendTerminalControl('arrowDown')")
->toContain("sendTerminalControl('arrowLeft')")
->toContain("sendTerminalControl('arrowRight')")
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
->toContain('overflow-x-auto')
->toContain('whitespace-nowrap')
->toContain('pasteFromClipboard()')
->toContain('copyTerminalSelection()')
->toContain("sendTerminalControl('tab')")
->toContain("sendTerminalControl('escape')")
->not->toContain("sendTerminalControl('ctrlC')")
->not->toContain('pasteFromClipboard()')
->not->toContain('copyTerminalSelection()')
->toContain('mobileToolbarCollapsed')
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2' : 'relative mt-2 shrink-0'")
->toContain('sendTerminalControl(\'escape\')">ESC</button>')
->toContain("toggleTerminalModifier('ctrl')")
->toContain("toggleTerminalModifier('alt')")
->toContain("sendTerminalKey('/')")
->toContain("sendTerminalKey('|')")
->toContain("sendTerminalKey('~')")
->toContain("sendTerminalKey('-')")
->toContain("sendTerminalControl('ctrlC')")
->toContain("sendTerminalControl('ctrlBackslash')")
->toContain("sendTerminalControl('ctrlS')")
->toContain("sendTerminalControl('ctrlZ')")
->not->toContain("sendTerminalControl('arrowUp')")
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key');
->toContain('.terminal-mobile-key')
->toContain('min-h-8')
->toContain('rounded-full')
->toContain('.terminal-key-row')
->toContain('background: transparent;')
->toContain('var(--terminal-scrollbar');
});
it('shows the terminal key row outside fullscreen mode', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalView)
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
->and($terminalClient)
->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')")
->not->toContain("this.\$refs.terminalWrapper.style.display = 'block'");
});
it('sends terminal mobile toolbar controls through the websocket', function () {
@ -365,8 +384,15 @@
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
->toContain("ctrlBackslash: '\\x1c'")
->toContain("ctrlS: '\\x13'")
->toContain("ctrlZ: '\\x1a'")
->toContain('toggleTerminalModifier(modifier)')
->toContain('sendTerminalKey(key)')
->toContain('navigator.clipboard.readText()')
->toContain('navigator.clipboard.writeText(selection)');
->toContain('navigator.clipboard.writeText(selection)')
->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.sendMessage({ message: data });")
->not->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.term.focus();");
});
it('uses terminal host dimensions when resizing so mobile controls do not cover terminal rows', function () {
@ -379,24 +405,40 @@
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
it('keeps the fullscreen mobile toolbar above the software keyboard', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
->not->toContain('updateFullscreenLayout()')
->not->toContain('terminalFullscreenHeight')
->not->toContain('window.visualViewport?.height')
->toContain('keyboardInset: 0')
->toContain('keyboardAnchorTop: 0')
->toContain('keyboardViewportHeight: 0')
->toContain('updateKeyboardInset()')
->toContain('window.visualViewport')
->toContain('viewport.height + viewport.offsetTop')
->toContain('this.keyboardViewportHeight - visualBottom')
->toContain('this.keyboardAnchorTop = Math.round(visualBottom)')
->toContain('syncFullscreenShellWithKeyboard(viewport)')
->toContain("wrapper.style.setProperty('bottom', 'auto', 'important')")
->toContain("window.visualViewport?.addEventListener('resize', this.syncKeyboardInset)")
->toContain("window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset)")
->toContain("window.addEventListener('resize', this.syncKeyboardInset)")
->toContain("window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset)")
->toContain("window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset)")
->toContain("window.removeEventListener('resize', this.syncKeyboardInset)")
->and($terminalView)
->toContain("mobileToolbarCollapsed\n ? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'\n : 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2'");
->toContain("'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'")
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2'")
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2'");
});
it('resizes after toggling the mobile terminal toolbar', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
it('resizes after the mobile keyboard viewport changes', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalView)
->toContain('$nextTick(() => resizeTerminal())');
expect($terminalClient)
->toContain('window.visualViewport')
->toContain('this.$nextTick(() => this.resizeTerminal())');
});
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
@ -438,6 +480,13 @@
->toContain('color-mix(in srgb, var(--terminal-scrollbar');
});
it('keeps the application terminal fullscreen control visible on mobile', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100');
});
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'));

View file

@ -0,0 +1,28 @@
<?php
use App\Models\Server;
use App\Models\User;
use Database\Seeders\SentinelSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('uses the configured development Sentinel URL for seeded servers', function () {
DB::table('instance_settings')->insert(['id' => 0]);
$user = User::factory()->create();
$server = Server::factory()->create([
'team_id' => $user->teams()->first()->id,
]);
DB::table('server_settings')->where('id', $server->settings->id)->update([
'sentinel_custom_url' => 'http://host.docker.internal:8000',
]);
config()->set('app.env', 'local');
config()->set('constants.sentinel.dev_url', 'https://coolify-dev.example.com:8000');
app(SentinelSeeder::class)->run();
expect($server->settings->fresh()->sentinel_custom_url)
->toBe('https://coolify-dev.example.com:8000');
});

View file

@ -14,9 +14,12 @@
expect(substr_count($serverIndex, '<x-status-badge'))->toBe(1)
->and($serverIndex)
->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'")
->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()')
->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'")
->toContain("\$isReady => 'success'")
->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'")
->toContain("default => 'warning'")
->toContain("default => 'error'")
->toContain("server.statusType === 'success' ? 'border-emerald-500/70'")
->toContain("server.statusType === 'warning' ? 'border-amber-500/70'")
->toContain("'border-red-500/70'")
@ -24,6 +27,15 @@
->toContain(':aria-label="`Server status: ${server.status}`"');
});
test('dashboard server cards warn when proxy or sentinel needs attention', function () {
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
expect($dashboard)
->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'")
->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()')
->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']");
});
test('server table keeps status text without a badge', function () {
$serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php'));

View file

@ -80,6 +80,16 @@
->not->toMatch('/<a title="Terminal"[^>]*wireNavigate\(\)/s');
});
it('keeps the server terminal navigation active during Livewire requests', function () {
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
expect($sidebar)
->toContain("'active' => \$activeMenu === 'terminal'")
->and($navbar)
->toContain("'active' => \$currentRoute === 'server.command'");
});
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'));