diff --git a/DESIGN.md b/DESIGN.md
index 69f4f91ef..72ab85abe 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -627,6 +627,16 @@ Application and server browser terminals use the same browser-oriented console
shell, theme picker, compact header controls, and outline `browser-terminal`
Reicon. Hide a container switcher when only one container exists.
+The themed console shell belongs to an open session. Before a target is
+selected, the global Terminal page stays a normal top-level destination: a
+full-width layer card titled `Start a terminal session`, its filter input in
+the card header actions, and grouped `Servers` / `Containers` rows reusing the
+command-palette row classes. Do not render an empty full-height console canvas
+just to host the target picker, and do not offer the console theme selector
+before a session owns that canvas. Rows show the target name, a muted server
+column that only appears when the team has more than one server, and the shared
+chevron. Group headers stick to the top of the scrolling list and carry a count.
+
### Logs
Runtime and deployment logs should feel like a clean terminal surface:
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 06b9f2d24..601c364de 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2884,6 +2884,10 @@ class ApplicationsController extends Controller
], 422);
}
+ $requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled')
+ || $request->has('http_basic_auth_username')
+ || $request->has('http_basic_auth_password');
+
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
$validationErrors = [];
@@ -2901,10 +2905,6 @@ class ApplicationsController extends Controller
}
}
}
- if ($request->has('is_http_basic_auth_enabled') && $application->is_container_label_readonly_enabled === false) {
- $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
- $application->save();
- }
// For dockercompose applications, domains (fqdn) field should not be used
// Only docker_compose_domains should be used to set domains for individual services
@@ -3119,7 +3119,7 @@ class ApplicationsController extends Controller
// Must run after fqdn is filled: flags are kept only for domains the app still has.
$application->setNoindexDomains($request->input('noindex_domains') ?? []);
}
- if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains) && $server->isProxyShouldRun()) {
+ if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
$application->save();
diff --git a/resources/css/app.css b/resources/css/app.css
index 53565c508..9dd88d1dd 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -727,6 +727,86 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina
backdrop-filter: blur(16px);
}
+/* Pre-session target list: a normal page card, not the themed console canvas. */
+.terminal-target-card .application-settings-section-body {
+ overflow: hidden;
+}
+
+/* The header inset is tuned for 32px buttons. A full-width filter needs the
+ same gutter on both sides once the actions row wraps below the title.
+ Matches the base header selector's specificity so the shorthand cannot win. */
+@media (max-width: 640px) {
+ .application-settings-section.terminal-target-card > :is(header, .application-settings-section-header) {
+ padding-right: 1rem;
+ }
+}
+
+.terminal-target-card-list {
+ max-height: min(70vh, 34rem);
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--coollabs-fill) transparent;
+}
+
+.terminal-target-card-list::-webkit-scrollbar {
+ width: 8px;
+}
+
+.terminal-target-card-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.terminal-target-card-list::-webkit-scrollbar-thumb {
+ border: 2px solid transparent;
+ border-radius: 9999px;
+ background-color: var(--coollabs-fill);
+ background-clip: padding-box;
+}
+
+/* Group headers stay readable while scrolling long container lists. */
+.terminal-target-group-label {
+ display: flex;
+ position: sticky;
+ z-index: 1;
+ top: 0;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.5rem 0.375rem;
+ background: var(--coollabs-base);
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ color: var(--coollabs-subtle);
+}
+
+.terminal-target-group-count {
+ border-radius: 9999px;
+ background: var(--coollabs-fill);
+ padding: 0 0.375rem;
+ font-size: 0.625rem;
+ line-height: 1rem;
+ font-variant-numeric: tabular-nums;
+}
+
+/* The server column only earns its space once the row is wide enough. */
+.terminal-target-item-server {
+ display: none;
+ max-width: 14rem;
+ flex-shrink: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.6875rem;
+ line-height: 1rem;
+ color: var(--coollabs-subtle);
+}
+
+@media (min-width: 640px) {
+ .terminal-target-item-server {
+ display: block;
+ }
+}
+
.terminal-session-panel {
border: 0;
border-radius: 0.75rem;
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index 766bd5f86..097a499c4 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -934,6 +934,7 @@ export function initializeTerminalComponent() {
tab: '\t',
escape: '\x1b',
ctrlC: '\x03',
+ ctrlD: '\x04',
ctrlBackslash: '\x1c',
ctrlS: '\x13',
ctrlZ: '\x1a'
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php
index e219ceef5..b4a0961a0 100644
--- a/resources/views/livewire/project/shared/terminal.blade.php
+++ b/resources/views/livewire/project/shared/terminal.blade.php
@@ -76,7 +76,7 @@
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')">
-
@@ -96,6 +96,7 @@
~
-
^C
+
^D
^\
^S
^Z
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php
index 8694aa0bf..6ccc7b9d0 100644
--- a/resources/views/livewire/terminal/index.blade.php
+++ b/resources/views/livewire/terminal/index.blade.php
@@ -22,6 +22,8 @@
$terminalOptions[] = [
'value' => $server->uuid,
'label' => $server->name.' · Server',
+ 'name' => $server->name,
+ 'server' => $server->name,
'type' => 'server',
];
@@ -30,6 +32,8 @@
$terminalOptions[] = [
'value' => $container['uuid'],
'label' => $server->name.' · '.$container['name'],
+ 'name' => $container['name'],
+ 'server' => $server->name,
'type' => 'container',
];
}
@@ -41,7 +45,8 @@
?? 'Select a server or container';
@endphp
-
+
Terminal | Coolify
@@ -57,11 +62,12 @@
-
@if ($selected_uuid === 'default')
-
-
-
-
-
-
-
Start a terminal session
-
- {{ $isLoadingContainers ? 'Finding available servers and containers…' : 'Choose a server or container. The terminal will open after you select a target.' }}
-
- @if (! $isLoadingContainers && $servers->isNotEmpty())
-
+
-
- @if ($isLoadingContainers)
-
-
-
- Loading servers and containers…
-
-
- @elseif ($servers->isEmpty())
-
-
No terminal targets available
-
Connect a reachable server and enable terminal access.
-
- @else
+
+ @endif
+
+ @if ($isLoadingContainers)
+
+
+ Finding available servers and containers…
+
+ @elseif ($servers->isEmpty())
+
+ @else
+
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+ class="px-3 py-8 text-center text-[13px] text-neutral-500 dark:text-fg-dim">
No matching targets
- @endif
-
-
-
+
+ @endif
+
+
@else
-
@@ -301,22 +301,22 @@
class="h-8! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2! pl-7! text-[11px]! text-white! shadow-none! placeholder:text-white/30 focus:border-accent! focus:ring-0!">
-
+
diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php
index 6f54e57e8..c1d08bf38 100644
--- a/tests/Feature/Api/ApplicationSettingsApiTest.php
+++ b/tests/Feature/Api/ApplicationSettingsApiTest.php
@@ -14,6 +14,8 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
+ config(['app.maintenance.driver' => 'file']);
+
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
@@ -136,6 +138,53 @@ test('proxy settings regenerate managed labels', function () {
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
});
+test('http basic auth updates regenerate managed labels', function () {
+ $this->application->settings->update(['is_container_label_readonly_enabled' => true]);
+ $this->application->update([
+ 'fqdn' => 'https://app.example.com',
+ 'is_http_basic_auth_enabled' => false,
+ 'http_basic_auth_username' => null,
+ 'http_basic_auth_password' => null,
+ 'custom_labels' => base64_encode('sentinel-label=true'),
+ ]);
+
+ $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'is_http_basic_auth_enabled' => true,
+ 'http_basic_auth_username' => 'api-user',
+ 'http_basic_auth_password' => 'api-password',
+ ])
+ ->assertOk();
+
+ $application = $this->application->fresh();
+ $labels = $application->parseContainerLabels();
+
+ expect((bool) $application->is_http_basic_auth_enabled)->toBeTrue()
+ ->and($application->http_basic_auth_username)->toBe('api-user')
+ ->and($application->http_basic_auth_password)->toBe('api-password')
+ ->and($labels)->toContain('basicauth')
+ ->and($labels)->toContain('api-user')
+ ->and($labels)->not->toContain('sentinel-label=true');
+});
+
+test('http basic auth updates preserve user-managed labels', function () {
+ $this->application->settings->update(['is_container_label_readonly_enabled' => false]);
+ $this->application->update([
+ 'fqdn' => 'https://app.example.com',
+ 'custom_labels' => base64_encode('sentinel-label=true'),
+ ]);
+
+ $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'is_http_basic_auth_enabled' => true,
+ 'http_basic_auth_username' => 'api-user',
+ 'http_basic_auth_password' => 'api-password',
+ ])
+ ->assertOk();
+
+ expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true');
+});
+
test('rejects invalid boolean application settings', function () {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php
index 91c5ae453..85191bc25 100644
--- a/tests/Feature/RealtimeTerminalPackagingTest.php
+++ b/tests/Feature/RealtimeTerminalPackagingTest.php
@@ -98,7 +98,7 @@ it('uses a larger high-contrast label for every terminal loading phase', functio
$styles = file_get_contents(resource_path('css/app.css'));
expect(substr_count($resourceView.$terminalView.$globalView, 'terminal-loading-label'))
- ->toBeGreaterThanOrEqual(3)
+ ->toBeGreaterThanOrEqual(2)
->and($styles)
->toContain('.terminal-loading-label')
->toContain('font-size: 0.875rem;')
@@ -323,12 +323,12 @@ it('preserves terminal scrollback across transient reconnects', function () {
->not->toContain("this.term.reset();\n this.term.clear();");
});
-it('renders a horizontally scrollable mobile terminal key row', function () {
+it('renders a horizontally scrollable terminal key row only on mobile', 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)
- ->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
+ ->toContain('class="sm:hidden"')
->toContain('overflow-x-auto')
->toContain('whitespace-nowrap')
->toContain('pasteFromClipboard()')
@@ -343,11 +343,12 @@ it('renders a horizontally scrollable mobile terminal key row', function () {
->toContain("sendTerminalKey('~')")
->toContain("sendTerminalKey('-')")
->toContain("sendTerminalControl('ctrlC')")
+ ->toContain("sendTerminalControl('ctrlD')")
->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("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')")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key')
@@ -358,13 +359,13 @@ it('renders a horizontally scrollable mobile terminal key row', function () {
->toContain('var(--terminal-scrollbar');
});
-it('shows the terminal key row outside fullscreen mode', function () {
+it('shows the mobile 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("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')")
+ ->toContain('class="sm:hidden"')
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
->and($terminalClient)
->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')")
@@ -384,6 +385,7 @@ it('sends terminal mobile toolbar controls through the websocket', function () {
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
+ ->toContain("ctrlD: '\\x04'")
->toContain("ctrlBackslash: '\\x1c'")
->toContain("ctrlS: '\\x13'")
->toContain("ctrlZ: '\\x1a'")
diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php
index 6e1308608..0a7890bf3 100644
--- a/tests/Feature/TerminalPageHeaderTest.php
+++ b/tests/Feature/TerminalPageHeaderTest.php
@@ -11,25 +11,61 @@ it('shows the initial terminal target launcher and keeps the header picker for s
->toContain('this.targetChosen = true;');
});
-it('shows a centered themed target canvas before loading xterm', function () {
+it('shows the pre-session target list as a page card instead of a full-height console canvas', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
+ $pickerBranch = str($view)
+ ->after("@if (\$selected_uuid === 'default')")
+ ->before("\n @else")
+ ->toString();
+
expect($view)
->toContain("@if (\$selected_uuid === 'default')")
->toContain('data-terminal-target-picker="page"')
->toContain('data-terminal-target-canvas')
- ->toContain('items-center justify-center')
- ->toContain(':data-console-theme="consoleTheme"')
- ->toContain("@else\n toContain("@else\n
and($pickerBranch)
+ ->toContain('toContain('title="Start a terminal session"')
+ // The themed console canvas belongs to an open session, not to target selection.
+ ->not->toContain('application-console-shell')
+ ->not->toContain(':data-console-theme="consoleTheme"')
+ ->not->toContain('toContain('data-terminal-target-picker="page"')
- ->toContain('flex max-h-full w-full max-w-lg flex-col')
- ->toContain('class="terminal-target-list min-h-0 flex-1 overflow-y-auto p-2"');
+ ->toContain('aria-label="Filter terminal targets"')
+ ->toContain('class="application-settings-workspace flex w-full min-w-0 flex-col"')
+ ->toContain('class="terminal-target-card-list"')
+ ->and($styles)
+ ->toContain('.terminal-target-card .application-settings-section-body')
+ ->toMatch('/\.terminal-target-card-list\s*\{[^}]*max-height:\s*min\(70vh, 34rem\);/s')
+ ->toMatch('/\.terminal-target-group-label\s*\{[^}]*position:\s*sticky;/s')
+ // The wrapped filter needs the same gutter on both sides of the header, and the
+ // override must match the base header selector's specificity to win.
+ ->toMatch('/\.application-settings-section\.terminal-target-card > :is\(header, \.application-settings-section-header\)\s*\{\s*padding-right:\s*1rem;/s');
+});
+
+it('splits target rows into a name and a server column that only shows with several servers', function () {
+ $view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
+ $styles = file_get_contents(resource_path('css/app.css'));
+
+ expect($view)
+ ->toContain("'name' => \$container['name'],")
+ ->toContain("'server' => \$server->name,")
+ ->toContain('multipleServers: @js($servers->count() > 1)')
+ ->toContain('x-text="target.name"')
+ ->toContain('class="terminal-target-item-server" x-text="target.server"')
+ ->toContain('x-show="multipleServers"')
+ // The label keeps both parts so filtering still matches the server name.
+ ->toContain("'label' => \$server->name.' · '.\$container['name'],")
+ ->and($styles)
+ ->toContain('.terminal-target-item-server');
});
it('loads targets inside the themed session picker with an accent scrollbar', function () {
@@ -100,12 +136,12 @@ it('uses floating rounded controls instead of the legacy terminal header bar', f
->not->toContain('application-console-header flex h-[30px]');
});
-it('keeps the theme selector identical before and during a terminal session', function () {
+it('offers the console theme selector only while a session owns the canvas', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
expect(substr_count($view, 'toBe(2)
+ ->toBe(1)
->and($selector)
->toContain('terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-medium')
->and($view)
@@ -130,19 +166,20 @@ it('reuses one chevron theme selector before and during terminal sessions', func
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
expect(substr_count($globalView.$resourceView, 'toBe(3)
+ ->toBe(2)
->and($selector)
->toContain('terminal-theme-trigger')
->toContain('viewBox="0 0 12 12"')
->toContain('m3.5 4.75 2.5 2.5 2.5-2.5');
});
-it('keeps the pre-session theme selector out of the centered picker layout', function () {
+it('keeps the theme selector in the floating session toolbar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
- ->toContain('class="absolute top-3 right-3 z-20"')
- ->toContain('toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
+ ->toContain('not->toContain('class="absolute top-3 right-3 z-20"');
});
it('updates the owning terminal theme directly so its label and canvas stay synchronized', function () {
@@ -174,8 +211,9 @@ it('locks the terminal page to the viewport so the document does not scroll', fu
$appCss = file_get_contents(resource_path('css/app.css'));
expect($view)
- ->toContain('class="terminal-page application-settings-form"')
- ->toContain('class="terminal-page-console min-h-0 w-full flex-1 overflow-hidden"')
+ // The viewport lock only applies once a session owns the canvas.
+ ->toContain("class=\"{{ \$selected_uuid === 'default' ? '' : 'terminal-page' }} application-settings-form\"")
+ ->toContain("'terminal-page-console min-h-0 w-full flex-1 overflow-hidden'")
->not->toContain('h-[calc(100dvh-11rem)]')
->not->toContain('min-h-[32rem]')
->and($appCss)