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 01/10] 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 @@{{ $message }}
@enderror -