From 3f47d4188037a2367c506f88cb4371a420508866 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:33:11 +0200 Subject: [PATCH] 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. --- .env.development.example | 1 + app/Livewire/Profile/Index.php | 6 +- config/constants.php | 1 + database/seeders/SentinelSeeder.php | 8 ++ resources/css/app.css | 29 +++-- resources/js/terminal.js | 121 +++++++++++++++++- .../components/modal-confirmation.blade.php | 3 +- .../views/components/server/sidebar.blade.php | 2 +- resources/views/livewire/dashboard.blade.php | 4 + .../views/livewire/profile/index.blade.php | 44 +++++-- .../project/shared/terminal.blade.php | 58 ++++----- .../views/livewire/server/index.blade.php | 6 +- .../views/livewire/server/navbar.blade.php | 2 +- tests/Feature/DeploymentLogsLayoutTest.php | 2 + tests/Feature/ModalScrollLockTest.php | 9 ++ tests/Feature/ProfileAvatarTest.php | 11 +- .../Feature/RealtimeTerminalPackagingTest.php | 113 +++++++++++----- tests/Feature/SentinelSeederTest.php | 28 ++++ .../ServerStatusIndicatorDesignTest.php | 14 +- tests/Feature/TerminalPageHeaderTest.php | 10 ++ 20 files changed, 367 insertions(+), 105 deletions(-) create mode 100644 tests/Feature/ModalScrollLockTest.php create mode 100644 tests/Feature/SentinelSeederTest.php diff --git a/.env.development.example b/.env.development.example index 29162a59a..6c0fe2189 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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 diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 99c2567f2..a20a1231b 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -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 @@ class Index extends Component $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; } } diff --git a/config/constants.php b/config/constants.php index aa1b5c36c..fc3179059 100644 --- a/config/constants.php +++ b/config/constants.php @@ -99,6 +99,7 @@ return [ ], '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 diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php index 3cf913933..ebae97078 100644 --- a/database/seeders/SentinelSeeder.php +++ b/database/seeders/SentinelSeeder.php @@ -16,6 +16,14 @@ class SentinelSeeder extends Seeder 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()) { diff --git a/resources/css/app.css b/resources/css/app.css index 1d73bf328..ad3412bf2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -78,7 +78,13 @@ @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 @@ input[type="search"]::-webkit-search-results-decoration { 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; } diff --git a/resources/js/terminal.js b/resources/js/terminal.js index bdcc3824d..766bd5f86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -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 . this.salvageStrayFullscreenNodes(); + this.updateKeyboardInset?.(); this.scheduleTerminalResize(); }, diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 4c0a63b78..8cc233a0c 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -328,7 +328,8 @@ class="w-auto" isError @click=" if (dispatchEvent) { - $wire.dispatch(dispatchEventType, dispatchEventMessage); + modalOpen = false; + $nextTick(() => $wire.dispatch(dispatchEventType, dispatchEventMessage)); } if (confirmWithPassword && !skipPasswordConfirmation) { step++; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index d0254054b..006efb2ca 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -83,7 +83,7 @@ [ 'label' => 'Terminal', 'route' => 'server.command', - 'active' => request()->routeIs('server.command'), + 'active' => $activeMenu === 'terminal', 'icon' => 'browser-terminal', 'group' => 'Operations', 'navigate' => false, diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 652dbb0e2..eb48314bf 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -169,11 +169,15 @@
@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 diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 090be9fd9..8ad2e9207 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -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 @@ @endif
- +
+ + + + + @if (auth()->user()->avatar_path) + Remove + @endif +

@error('avatar')

{{ $message }}

@enderror -
- - Upload picture - Compressing… - - @if (auth()->user()->avatar_path) - Remove - @endif -
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index a96a28ab1..e219ceef5 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -60,10 +60,6 @@ x-text="connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : (starting ? 'connecting…' : (connectionState === 'connecting' ? 'connecting…' : 'choose a container to start a session'))"> -
-
@else
-
-
- Terminal keys - -
-
- - - - - - -
+ :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> +
+ + + + + + + + + + + + + +
@@ -124,7 +114,7 @@ ') + ->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 @@ it('sends terminal mobile toolbar controls through the websocket', function () { ->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 @@ it('uses terminal host dimensions when resizing so mobile controls do not cover ->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 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function ->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')); diff --git a/tests/Feature/SentinelSeederTest.php b/tests/Feature/SentinelSeederTest.php new file mode 100644 index 000000000..b905bfcbe --- /dev/null +++ b/tests/Feature/SentinelSeederTest.php @@ -0,0 +1,28 @@ +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'); +}); diff --git a/tests/Feature/ServerStatusIndicatorDesignTest.php b/tests/Feature/ServerStatusIndicatorDesignTest.php index e29857c49..34e0f4cbd 100644 --- a/tests/Feature/ServerStatusIndicatorDesignTest.php +++ b/tests/Feature/ServerStatusIndicatorDesignTest.php @@ -14,9 +14,12 @@ test('server cards use icon borders instead of ready badges', function () { expect(substr_count($serverIndex, '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 @@ test('server cards use icon borders instead of ready badges', function () { ->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')); diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php index d4bfd925a..6e1308608 100644 --- a/tests/Feature/TerminalPageHeaderTest.php +++ b/tests/Feature/TerminalPageHeaderTest.php @@ -80,6 +80,16 @@ it('opens the global terminal outside Livewire navigation like resource terminal ->not->toMatch('/]*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'));