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:
parent
32bf1860d4
commit
3f47d41880
20 changed files with 367 additions and 105 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'active' => $activeMenu === 'terminal',
|
||||
'icon' => 'browser-terminal',
|
||||
'group' => 'Operations',
|
||||
'navigate' => false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
: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"
|
||||
x-on:click="sendTerminalControl('tab')">Tab</button>
|
||||
: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"
|
||||
x-on:click="sendTerminalControl('escape')">Esc</button>
|
||||
</div>
|
||||
: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">
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;");
|
||||
});
|
||||
|
|
|
|||
9
tests/Feature/ModalScrollLockTest.php
Normal file
9
tests/Feature/ModalScrollLockTest.php
Normal 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'
|
||||
);
|
||||
});
|
||||
|
|
@ -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',");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
28
tests/Feature/SentinelSeederTest.php
Normal file
28
tests/Feature/SentinelSeederTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue