From 2ff0233f478affa378c53887a7c64b57c81eef8a Mon Sep 17 00:00:00 2001 From: Selim Salihovic Date: Tue, 31 Mar 2026 12:05:37 +0200 Subject: [PATCH 01/95] feat: add configurable Horizon dashboard admin access Add HORIZON_ALLOWED_EMAILS env var to grant additional users access to the Horizon dashboard. Root user (User ID 0) always retains access. Co-Authored-By: Claude Sonnet 4.5 --- .env.development.example | 5 +++++ app/Providers/HorizonServiceProvider.php | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.env.development.example b/.env.development.example index 594b89201..8537a9b99 100644 --- a/.env.development.example +++ b/.env.development.example @@ -24,6 +24,11 @@ RAY_ENABLED=false # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false +# Laravel Horizon Admin Access +# Comma-separated list of email addresses allowed to access /horizon dashboard +# The root user (User ID 0) always has access +# HORIZON_ALLOWED_EMAILS=admin@example.com,devops@example.com + # Enable Laravel Nightwatch monitoring NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 0caa3a3a9..037636496 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -55,9 +55,18 @@ protected function gate(): void Gate::define('viewHorizon', function ($user) { $root_user = User::find(0); - return in_array($user->email, [ - $root_user->email, - ]); + // Get additional allowed emails from environment variable + $allowedEmails = array_filter( + array_map('trim', explode(',', env('HORIZON_ALLOWED_EMAILS', ''))) + ); + + // Merge root user email with additional allowed emails + $authorizedEmails = array_merge( + [$root_user->email], + $allowedEmails + ); + + return in_array($user->email, $authorizedEmails); }); } } From 419106696a44864dbba29ecf3299e4b05ef1ac37 Mon Sep 17 00:00:00 2001 From: Himanshu Ahuja Date: Mon, 10 Aug 2026 18:24:16 -0400 Subject: [PATCH 02/95] fix(backups): forward per-backup timeout to the ssh timeout wrapper instant_remote_process() applied its timeout only to Process::timeout(), while the shell-level timeout wrapper around ssh always used the global 3600s default, killing long backups and scheduled tasks at 1h with ssh exit code 255. Also applies the backup timeout to the S3 upload step and makes the default configurable via SSH_COMMAND_TIMEOUT. Co-Authored-By: Claude Fable 5 --- app/Jobs/DatabaseBackupJob.php | 2 +- bootstrap/helpers/remoteProcess.php | 2 +- config/constants.php | 2 +- tests/Feature/RemoteProcessTimeoutTest.php | 103 +++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/RemoteProcessTimeoutTest.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 104a84a1b..d432a3eeb 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -785,7 +785,7 @@ private function upload_to_s3(): void $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; - instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); + instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->s3_uploaded = true; } catch (Throwable $e) { diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 8d1a30a62..982dda551 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -177,7 +177,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool return SshRetryHandler::retry( function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing, (int) $effectiveTimeout); $process = Process::timeout($effectiveTimeout)->run($sshCommand); $output = trim($process->output()); diff --git a/config/constants.php b/config/constants.php index aa1b5c36c..fc15a6899 100644 --- a/config/constants.php +++ b/config/constants.php @@ -77,7 +77,7 @@ 'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans 'connection_timeout' => 10, 'server_interval' => 20, - 'command_timeout' => 3600, + 'command_timeout' => env('SSH_COMMAND_TIMEOUT', 3600), 'max_retries' => env('SSH_MAX_RETRIES', 3), 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds diff --git a/tests/Feature/RemoteProcessTimeoutTest.php b/tests/Feature/RemoteProcessTimeoutTest.php new file mode 100644 index 000000000..9bc8e12ae --- /dev/null +++ b/tests/Feature/RemoteProcessTimeoutTest.php @@ -0,0 +1,103 @@ +create(); + $team = $user->teams()->first(); + + $privateKeyContent = '-----BEGIN OPENSSH PRIVATE KEY----- +'. + 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +'. + 'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +'. + 'hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +'. + 'AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +'. + 'uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +'. + '-----END OPENSSH PRIVATE KEY-----'; + + $privateKey = PrivateKey::create([ + 'name' => 'timeout-test-key-'.uniqid(), + 'private_key' => $privateKeyContent, + 'team_id' => $team->id, + ]); + + Storage::fake('ssh-keys'); + Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); + + Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); + + return $server; +} + +it('wraps ssh commands with an explicitly passed command timeout', function () { + config(['constants.ssh.mux_enabled' => false]); + $server = makeTimeoutTestServer(); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 7200); + + expect($command)->toStartWith('timeout 7200 ssh '); +}); + +it('wraps ssh commands with the configured default timeout when none is passed', function () { + config([ + 'constants.ssh.mux_enabled' => false, + 'constants.ssh.command_timeout' => 1234, + ]); + $server = makeTimeoutTestServer(); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); + + expect($command)->toStartWith('timeout 1234 ssh '); +}); + +it('forwards the per-call timeout of instant_remote_process to the ssh timeout wrapper', function () { + config(['constants.ssh.mux_enabled' => false]); + $server = makeTimeoutTestServer(); + + Process::fake(); + + instant_remote_process(['echo ok'], $server, timeout: 7200, disableMultiplexing: true); + + Process::assertRan(fn ($process) => str_starts_with($process->command, 'timeout 7200 ssh ')); +}); + +it('uses the configured default timeout in instant_remote_process when no timeout is passed', function () { + config([ + 'constants.ssh.mux_enabled' => false, + 'constants.ssh.command_timeout' => 1234, + ]); + $server = makeTimeoutTestServer(); + + Process::fake(); + + instant_remote_process(['echo ok'], $server, disableMultiplexing: true); + + Process::assertRan(fn ($process) => str_starts_with($process->command, 'timeout 1234 ssh ')); +}); From 178b1592fe4a43afd32391407c4c0a8876a7a6c4 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:49:21 +0200 Subject: [PATCH 03/95] chore(workflows): disable branch checks on pr-quality --- .github/workflows/pr-quality.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml index 45a695ddc..5913496dd 100644 --- a/.github/workflows/pr-quality.yaml +++ b/.github/workflows/pr-quality.yaml @@ -19,13 +19,10 @@ jobs: max-failures: 4 # PR Branch Checks - allowed-target-branches: "next" + allowed-target-branches: "" blocked-target-branches: "" allowed-source-branches: "" - blocked-source-branches: | - main - master - v4.x + blocked-source-branches: "" # PR Quality Checks max-negative-reactions: 0 From 28ceb5a9a84f05d901ab7cf5487e9d8060985188 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:38:53 +0200 Subject: [PATCH 04/95] feat(workflows): add pull request branch management workflow --- .github/workflows/chore-manage-pr-branch.yaml | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 .github/workflows/chore-manage-pr-branch.yaml diff --git a/.github/workflows/chore-manage-pr-branch.yaml b/.github/workflows/chore-manage-pr-branch.yaml new file mode 100644 index 000000000..65c81ab71 --- /dev/null +++ b/.github/workflows/chore-manage-pr-branch.yaml @@ -0,0 +1,182 @@ +name: Manage PR Branch + +# Runs *after* the "PR Quality" workflow finishes. This is required because +# PR Quality may close a PR that fails its checks, so we must wait for it to +# complete before deciding whether to retarget the PR's base branch. +on: + workflow_run: + workflows: ["PR Quality"] + types: + - completed + +permissions: + contents: read + pull-requests: write + +concurrency: + group: manage-pr-branch-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true + +jobs: + manage-branch: + runs-on: ubuntu-latest + steps: + - name: Retarget PR base branch based on category + uses: actions/github-script@v7 + with: + script: | + const run = context.payload.workflow_run; + + // Branch routing based on the "Category" section of the PR body. + // Bug fixes and one-click service changes ship in patch releases -> main. + // Everything else (features, improvements) -> next. + const MAIN_BRANCH = 'main'; + const NEXT_BRANCH = 'next'; + + // Maintainers/collaborators are trusted to pick their own base branch. + const EXEMPT_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + // Resolve the open PR from the triggering run. + // + // PR Quality runs on `pull_request_target`, so `run.head_sha` is the + // *base* branch tip, not the PR head — a commit-based lookup finds + // nothing. Instead match on the source branch (`head_branch`) and its + // owner (`head_repository.owner.login`), which uniquely identify the PR + // via the `owner:branch` head filter. This also works for forked PRs, + // where `workflow_run.pull_requests` is empty. + const headOwner = run.head_repository?.owner?.login; + const headBranch = run.head_branch; + + let prRef; + if (headOwner && headBranch) { + const { data: openPrs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${headOwner}:${headBranch}`, + per_page: 100, + }); + prRef = openPrs[0]; + } + + // Fallback: same-repo PRs may also be resolvable by commit association. + if (!prRef) { + const { data: associated } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: run.head_sha, + }); + prRef = associated.find(pr => pr.state === 'open'); + } + + if (!prRef) { + core.info('No open PR associated with this run (possibly closed by PR Quality). Skipping.'); + return; + } + + // Fetch the full PR to get an up-to-date body, base ref, and state. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prRef.number, + }); + + if (pr.state !== 'open') { + core.info(`PR #${pr.number} is not open. Skipping.`); + return; + } + + // Skip PRs opened by owners/members/collaborators — they choose their own base. + if (EXEMPT_ASSOCIATIONS.has(pr.author_association)) { + core.info(`PR #${pr.number} author association is ${pr.author_association}. Skipping.`); + return; + } + + // Skip if a maintainer has already changed the base branch manually. + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + + const baseChanges = timeline.filter(e => e.event === 'base_ref_changed'); + for (const change of baseChanges) { + const actor = change.actor?.login; + if (!actor) { + continue; + } + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: actor, + }); + // admin/maintain/write => trusted maintainer. + if (['admin', 'maintain', 'write'].includes(perm.permission)) { + core.info(`Base branch was changed manually by ${actor} (${perm.permission}). Skipping.`); + return; + } + } catch (error) { + core.info(`Could not resolve permission for ${actor}: ${error.message}`); + } + } + + // Parse the checked category checkboxes from the PR body. + const body = pr.body ?? ''; + const checked = []; + const checkboxRegex = /^\s*-\s*\[([ xX])\]\s*(.+?)\s*$/gm; + let match; + while ((match = checkboxRegex.exec(body)) !== null) { + if (match[1].toLowerCase() === 'x') { + checked.push(match[2].toLowerCase()); + } + } + + const includesAny = (labels) => labels.some(label => checked.some(c => c.includes(label))); + + const mainCategories = ['bug fix', 'adding new one click service', 'fixing or updating existing one click service']; + const nextCategories = ['improvement', 'new feature']; + + const wantsMain = includesAny(mainCategories); + const wantsNext = includesAny(nextCategories); + + if (!wantsMain && !wantsNext) { + core.info('No category selected in the PR body. Skipping.'); + return; + } + + // If categories from both groups are checked, prefer next: features and + // improvements can only be released from the development branch. + const targetBranch = wantsNext ? NEXT_BRANCH : MAIN_BRANCH; + + if (pr.base.ref === targetBranch) { + core.info(`PR #${pr.number} already targets ${targetBranch}. Nothing to do.`); + return; + } + + const previousBranch = pr.base.ref; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + base: targetBranch, + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: [ + `Based on the selected category, this PR's base branch was automatically changed from \`${previousBranch}\` to \`${targetBranch}\`.`, + '', + targetBranch === MAIN_BRANCH + ? 'Bug fixes and one-click service changes target `main`.' + : 'New features and improvements target `next`.', + '', + 'If you believe this is incorrect, please let a maintainer know.', + ].join('\n'), + }); + + core.info(`Retargeted PR #${pr.number}: ${previousBranch} -> ${targetBranch}.`); From bfdf3d3d2bb3d5a9bc6239803356b960cc7a17f0 Mon Sep 17 00:00:00 2001 From: drewmt Date: Wed, 12 Aug 2026 09:13:47 +0300 Subject: [PATCH 05/95] fix(api): regenerate basic auth labels after updates --- .../Api/ApplicationsController.php | 10 ++-- .../Api/ApplicationSettingsApiTest.php | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) 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 @@ public function update_by_uuid(Request $request) ], 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 @@ public function update_by_uuid(Request $request) } } } - 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 @@ public function update_by_uuid(Request $request) // 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/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 @@ 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 @@ function recommendedApplicationSettingsPayload(): array 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}", [ From 4e3def11e32ab6e5454c091d0aac58c07dfb11b0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:51:42 +0200 Subject: [PATCH 06/95] fix(ui): expose environment variable settings on mobile Collapse environment variable rows to the key and settings action on small screens, and let the key open the corresponding details modal. --- resources/css/app.css | 25 +++++++++++++++++++ .../show-hardcoded.blade.php | 9 +++++-- .../environment-variable/show.blade.php | 10 +++++--- .../EnvironmentVariableTableLayoutTest.php | 13 ++++++++-- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 67562aa08..67989a000 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2026,6 +2026,31 @@ .env-table-grid.env-table-grid-no-type { grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 3rem; } +@media (max-width: 768px) { + .environment-table-scroll .env-table-grid { + min-width: 0; + } + + .data-table-header.env-table-grid { + display: none; + } + + .data-table-row.env-table-grid { + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .data-table-row.env-table-grid > :not(:first-child):not(:last-child) { + display: none; + } + + .data-table-row.env-table-grid > :last-child { + display: block; + justify-self: end; + } +} + /* Shared variables only store value shape (multiline), not per-resource flags. */ .env-table-grid-shared { grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4.5rem 3rem; diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 301a4f882..84d03c0fe 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -2,7 +2,12 @@ x-show="typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $isPreview ? 'preview' : 'production' }}'">
- {{ $key }} + @if (filled($comment)) @endif @@ -26,7 +31,7 @@
- diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 134f55589..eaa680bbc 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -24,8 +24,12 @@ @endif - {{ $env->key }} + @if (! $isSharedVariable && filled($comment)) @endif @@ -84,7 +88,7 @@ - diff --git a/tests/Feature/EnvironmentVariableTableLayoutTest.php b/tests/Feature/EnvironmentVariableTableLayoutTest.php index f3bcccad3..7402fc350 100644 --- a/tests/Feature/EnvironmentVariableTableLayoutTest.php +++ b/tests/Feature/EnvironmentVariableTableLayoutTest.php @@ -80,16 +80,25 @@ expect($show)->toContain(''); }); -test('resource environment variables table remains horizontally scrollable on mobile', function () { +test('resource environment variables expose their settings action without horizontal scrolling on mobile', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php')); + $show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php')); + $hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php')); $css = file_get_contents(resource_path('css/app.css')); expect($view) ->toContain('environment-table-scroll') + ->and($show) + ->toContain('data-env-name-trigger') + ->toContain('data-env-settings-trigger') + ->and($hardcoded) + ->toContain('data-env-name-trigger') + ->toContain('data-env-settings-trigger') ->and($css) ->toContain(".environment-table-scroll {\n overflow-x: auto;") ->toContain(".environment-table-scroll .env-table-grid {\n min-width: 53rem;") - ->not->toContain('.data-table-row.env-table-grid > :nth-child') + ->toContain('.data-table-header.env-table-grid') + ->toContain('.data-table-row.env-table-grid > :last-child') ->not->toContain(".env-type-desktop {\n display: none"); }); From 8c88f6093a7986bd4c997a58bd41bb20952ef235 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:27:08 +0200 Subject: [PATCH 07/95] chore(release): bump default version to 4.3.1 --- config/constants.php | 2 +- tests/Unit/ProductionImageWorkflowTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/constants.php b/config/constants.php index 2b6ea06f8..47df32df6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.0', + 'version' => env('COOLIFY_VERSION') ?: '4.3.1', 'helper_version' => '1.0.14', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 7d34b28c7..428ec27a6 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -19,12 +19,12 @@ ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.0'"); + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.1'"); }); it('orders a maintenance development build before its stable release', function () { - expect(version_compare('4.3.0-dev.d64cbda3e', '4.3.0', '<'))->toBeTrue() - ->and(version_compare('4.3.0', '4.3.0-dev.d64cbda3e', '>'))->toBeTrue(); + expect(version_compare('4.3.1-dev.d64cbda3e', '4.3.1', '<'))->toBeTrue() + ->and(version_compare('4.3.1', '4.3.1-dev.d64cbda3e', '>'))->toBeTrue(); }); it('requires a reviewed draft release before building a stable version', function () { From 12b14bbefdd703a6d6276e5fb3850e263f9e0257 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:53:16 +0200 Subject: [PATCH 08/95] ci(release): support main during production branch rename Run production builds and changelog generation from both v4.x and main while excluding both branches from staging builds. --- .github/pull_request_template.md | 2 +- .github/workflows/coolify-release.yml | 6 +++--- .github/workflows/coolify-sha-build.yml | 2 +- .github/workflows/coolify-staging-build.yml | 1 + .github/workflows/generate-changelog.yml | 2 +- AGENTS.md | 4 ++-- tests/Unit/ProductionImageWorkflowTest.php | 15 ++++++--------- 7 files changed, 15 insertions(+), 17 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e1286eb22..c226b1538 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -46,6 +46,6 @@ ## Contributor Agreement > [!IMPORTANT] > -> - [ ] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. +> - [ ] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/HEAD/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. > - [ ] I have searched [existing issues](https://github.com/coollabsio/coolify/issues) and [pull requests](https://github.com/coollabsio/coolify/pulls) (including closed ones) to ensure this isn't a duplicate. > - [ ] I have tested all the changes thoroughly with a local development instance of Coolify and I am confident that they will work as expected when a maintainer tests them. diff --git a/.github/workflows/coolify-release.yml b/.github/workflows/coolify-release.yml index 7951005ee..315802bb3 100644 --- a/.github/workflows/coolify-release.yml +++ b/.github/workflows/coolify-release.yml @@ -29,10 +29,10 @@ jobs: release_id: ${{ steps.draft.outputs.release_id }} version: ${{ steps.version.outputs.version }} steps: - - name: Reject releases outside v4.x - if: ${{ github.ref_name != 'v4.x' }} + - name: Reject releases outside the production branch + if: ${{ ! contains(fromJSON('["v4.x", "main"]'), github.ref_name) }} run: | - echo "Fix releases must run from v4.x, not ${{ github.ref_name }}." + echo "Stable releases must run from v4.x or main, not ${{ github.ref_name }}." exit 1 - uses: actions/checkout@v5 diff --git a/.github/workflows/coolify-sha-build.yml b/.github/workflows/coolify-sha-build.yml index 522dc21f5..5b4e43002 100644 --- a/.github/workflows/coolify-sha-build.yml +++ b/.github/workflows/coolify-sha-build.yml @@ -2,7 +2,7 @@ name: Build Coolify (SHA) on: push: - branches: ["v4.x"] + branches: ["v4.x", "main"] permissions: contents: read diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index c5b70ca92..df0508c98 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -4,6 +4,7 @@ on: push: branches-ignore: - v4.x + - main - v3.x - '**v5.x**' paths-ignore: diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index a5fb7c69d..1a16ec1e2 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -2,7 +2,7 @@ name: Generate Changelog on: push: - branches: [ v4.x ] + branches: [ v4.x, main ] paths-ignore: - .github/workflows/coolify-helper.yml - .github/workflows/coolify-helper-next.yml diff --git a/AGENTS.md b/AGENTS.md index e81c62a3c..ebd2e7138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,9 +146,9 @@ ## Key Conventions ## Git Workflow -- Main branch: `v4.x` +- Production branch: `main` (`v4.x` during the branch rename) - Development branch: `next` -- PRs should target `v4.x` +- Fix PRs should target the current production branch; feature PRs should target `next` === foundation rules === diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 428ec27a6..ec6f735c7 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -7,8 +7,7 @@ expect($workflow) ->toContain('name: Build Coolify (SHA)') - ->toContain('branches: ["v4.x"]') - ->not->toContain('branches: ["v4.x", "main"]') + ->toContain('branches: ["v4.x", "main"]') ->toContain('sha-${{ github.sha }}-${{ matrix.arch }}') ->toContain('sha-${{ github.sha }}') ->toContain('php bootstrap/getVersion.php') @@ -34,7 +33,7 @@ ->toContain('name: Release Coolify Stable') ->toContain('workflow_dispatch:') ->toContain('tag:') - ->toContain('github.ref_name != \'v4.x\'') + ->toContain('contains(fromJSON(\'["v4.x", "main"]\'), github.ref_name)') ->toContain('github.paginate(github.rest.repos.listReleases') ->toContain('release.draft') ->toContain('release.prerelease') @@ -56,20 +55,18 @@ 'realtime' => 'coolify-realtime.yml', ]); -it('generates the production changelog only from v4.x', function () { +it('generates the production changelog from either production branch during the rename', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/generate-changelog.yml'); - expect($workflow) - ->toContain('branches: [ v4.x ]') - ->not->toContain('main'); + expect($workflow)->toContain('branches: [ v4.x, main ]'); }); -it('excludes only active production branches from staging builds', function () { +it('excludes both production branch names from staging builds during the rename', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml'); expect($workflow) ->toContain(' - v4.x') - ->not->toContain(' - main'); + ->toContain(' - main'); }); it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () { From 08627795a5f8b141b5144078d2ef5ce0d6e166ba Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:12:55 +0200 Subject: [PATCH 09/95] ci(release): standardize production workflows on main Route production builds, releases, and changelog generation through `main`, update contribution guidance for main/next branch targets, and cover the branch policy in workflow tests. --- .github/ISSUE_TEMPLATE/01_BUG_REPORT.yml | 2 + .github/ISSUE_TEMPLATE/config.yml | 6 +-- .github/workflows/coolify-helper.yml | 2 +- .github/workflows/coolify-realtime.yml | 2 +- .github/workflows/coolify-release.yml | 4 +- .github/workflows/coolify-sha-build.yml | 2 +- .github/workflows/coolify-staging-build.yml | 1 - .github/workflows/generate-changelog.yml | 2 +- AGENTS.md | 2 +- CONTRIBUTING.md | 19 ++++++--- RELEASE.md | 22 +++++++--- tests/Unit/ProductionImageWorkflowTest.php | 47 +++++++++++++++++---- 12 files changed, 79 insertions(+), 32 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml index d5106ab75..1159bfb2b 100644 --- a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml @@ -8,6 +8,8 @@ body: value: | > [!IMPORTANT] > **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.) + > + > If you plan to submit a fix, branch from `main` and target `main` with your pull request. - type: textarea attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 92c48e2d6..847969358 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -7,12 +7,12 @@ contact_links: - name: 💡 Feature Request url: https://github.com/coollabsio/coolify/discussions/categories/feature-requests - about: Suggest a new feature for Coolify. + about: Suggest a new feature for Coolify. Feature code should branch from `next` and target `next`. - name: ⚙️ Service Request url: https://github.com/coollabsio/coolify/discussions/categories/service-requests - about: Request a new service integration for Coolify. + about: Request a new service integration for Coolify. Service code should branch from `next` and target `next`. - name: 🔧 Improvements url: https://github.com/coollabsio/coolify/discussions/categories/improvements - about: Suggest improvements to existing features for Coolify. + about: Suggest improvements to existing features. Small fixes should target `main`; larger changes should target `next`. diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 06c5f9eb3..cf9eb4243 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -2,7 +2,7 @@ name: Coolify Helper Image on: push: - branches: [ "v4.x", "main" ] + branches: [ "main" ] paths: - .github/workflows/coolify-helper.yml - docker/coolify-helper/Dockerfile diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index cfcf8f200..538f5c992 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -2,7 +2,7 @@ name: Coolify Realtime on: push: - branches: [ "v4.x", "main" ] + branches: [ "main" ] paths: - .github/workflows/coolify-realtime.yml - docker/coolify-realtime/** diff --git a/.github/workflows/coolify-release.yml b/.github/workflows/coolify-release.yml index 315802bb3..4f735ab15 100644 --- a/.github/workflows/coolify-release.yml +++ b/.github/workflows/coolify-release.yml @@ -30,9 +30,9 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Reject releases outside the production branch - if: ${{ ! contains(fromJSON('["v4.x", "main"]'), github.ref_name) }} + if: ${{ github.ref_name != 'main' }} run: | - echo "Stable releases must run from v4.x or main, not ${{ github.ref_name }}." + echo "Stable releases must run from main, not ${{ github.ref_name }}." exit 1 - uses: actions/checkout@v5 diff --git a/.github/workflows/coolify-sha-build.yml b/.github/workflows/coolify-sha-build.yml index 5b4e43002..7ae7d7e68 100644 --- a/.github/workflows/coolify-sha-build.yml +++ b/.github/workflows/coolify-sha-build.yml @@ -2,7 +2,7 @@ name: Build Coolify (SHA) on: push: - branches: ["v4.x", "main"] + branches: ["main"] permissions: contents: read diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index df0508c98..ccbd14129 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -3,7 +3,6 @@ name: Staging Build on: push: branches-ignore: - - v4.x - main - v3.x - '**v5.x**' diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 1a16ec1e2..6e88c0d60 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -2,7 +2,7 @@ name: Generate Changelog on: push: - branches: [ v4.x, main ] + branches: [ main ] paths-ignore: - .github/workflows/coolify-helper.yml - .github/workflows/coolify-helper-next.yml diff --git a/AGENTS.md b/AGENTS.md index ebd2e7138..e35c5d74f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ ## Key Conventions ## Git Workflow -- Production branch: `main` (`v4.x` during the branch rename) +- Production branch: `main` - Development branch: `next` - Fix PRs should target the current production branch; feature PRs should target `next` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53ba6c6a1..73b048f4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,7 @@ ## State of the Project - A more complex user experience - Other smaller issues that need refinement -These limitations will be addressed in Coolify v5, which is in the planning stage. Because of this, major features, architectural changes, or significant UI changes will not be accepted for v4 at this stage. - -We welcome contributions that help stabilize v4 for a bug free experience. +These limitations will be addressed over time. Fixes and small improvements are accepted on the production line. New features and larger changes require prior discussion and must go through the development line. ## What Makes a Strong Contribution @@ -188,8 +186,19 @@ ## Submitting a Pull Request - GitHub will auto-populate the PR template - The contributor agreement in PR description must remain intact - Pull requests without the contributor agreement will be closed -- All pull requests must target the `next` branch -- PRs targeting other branches will be closed without review + +Choose the branch based on the type of change: + +| Change | Start from | Pull request target | +| --- | --- | --- | +| Fixes and small improvements | `main` | `main` | +| Security fixes | `main` | `main` | +| New features and larger changes | `next` | `next` | + +- For a fix, branch from `main` and target `main`. +- For a feature, branch from `next` and target `next`. +- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `next` so the fix is included there too. +- Pull requests targeting the wrong branch may be closed or asked to retarget. ## FAQ diff --git a/RELEASE.md b/RELEASE.md index 493c690f1..d278d7690 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,6 +11,13 @@ ## Branches Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow. +## Where changes go + +- Fixes, security updates, and small improvements target `main`. +- New features and larger changes target `next`. +- Merge `main` into `next` regularly so every production fix is included in the next release. +- Do not merge `next` into `main` until an RC is approved for a stable release. + ## Feature and RC flow ```text @@ -45,13 +52,14 @@ ## Hotfix flow main → hotfix/X.Y.Z → main → next ``` -1. Create `hotfix/X.Y.Z` from `main` and set the intended patch version. -2. Implement and test the fix. SHA images report `X.Y.Z-dev.`. -3. Merge the hotfix into `main`. -4. Create a reviewed draft GitHub Release named `vX.Y.Z`. -5. Run the stable release workflow from `main`. -6. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch. -7. Update the CDN only after the release is approved. +1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead. +2. Set the intended patch version. +3. Implement and test the fix. SHA images report `X.Y.Z-dev.`. +4. Merge the fix into `main`. +5. Create a reviewed draft GitHub Release named `vX.Y.Z`. +6. Run the stable release workflow from `main`. +7. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. +8. Update the CDN only after the release is approved. ## Image tags diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index ec6f735c7..54fb13bb3 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -7,7 +7,8 @@ expect($workflow) ->toContain('name: Build Coolify (SHA)') - ->toContain('branches: ["v4.x", "main"]') + ->toContain('branches: ["main"]') + ->not->toContain('v4.x') ->toContain('sha-${{ github.sha }}-${{ matrix.arch }}') ->toContain('sha-${{ github.sha }}') ->toContain('php bootstrap/getVersion.php') @@ -33,7 +34,7 @@ ->toContain('name: Release Coolify Stable') ->toContain('workflow_dispatch:') ->toContain('tag:') - ->toContain('contains(fromJSON(\'["v4.x", "main"]\'), github.ref_name)') + ->toContain("github.ref_name != 'main'") ->toContain('github.paginate(github.rest.repos.listReleases') ->toContain('release.draft') ->toContain('release.prerelease') @@ -46,27 +47,31 @@ ->not->toContain('generate-notes'); }); -it('keeps support image workflows ready for the production branch rename', function (string $workflowFile) { +it('runs support image workflows from main', function (string $workflowFile) { $workflow = file_get_contents(dirname(__DIR__, 2)."/.github/workflows/{$workflowFile}"); - expect($workflow)->toContain('branches: [ "v4.x", "main" ]'); + expect($workflow) + ->toContain('branches: [ "main" ]') + ->not->toContain('v4.x'); })->with([ 'helper' => 'coolify-helper.yml', 'realtime' => 'coolify-realtime.yml', ]); -it('generates the production changelog from either production branch during the rename', function () { +it('generates the production changelog from main', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/generate-changelog.yml'); - expect($workflow)->toContain('branches: [ v4.x, main ]'); + expect($workflow) + ->toContain('branches: [ main ]') + ->not->toContain('v4.x'); }); -it('excludes both production branch names from staging builds during the rename', function () { +it('excludes main from staging builds', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml'); expect($workflow) - ->toContain(' - v4.x') - ->toContain(' - main'); + ->toContain(' - main') + ->not->toContain(' - v4.x'); }); it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () { @@ -103,3 +108,27 @@ ->not->toContain('`edge`') ->not->toContain('promotes the existing SHA image'); }); + +it('documents pull request targets for fixes and features', function () { + $contributingGuide = file_get_contents(dirname(__DIR__, 2).'/CONTRIBUTING.md'); + + expect($contributingGuide) + ->toContain('Fixes and small improvements') + ->toContain('target `main`') + ->toContain('New features and larger changes') + ->toContain('target `next`') + ->toContain('branch from `main`') + ->toContain('branch from `next`') + ->not->toContain('All pull requests must target the `next` branch'); +}); + +it('guides issue authors to the correct contribution branch', function () { + $bugReport = file_get_contents(dirname(__DIR__, 2).'/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml'); + $issueConfig = file_get_contents(dirname(__DIR__, 2).'/.github/ISSUE_TEMPLATE/config.yml'); + + expect($bugReport) + ->toContain('branch from `main` and target `main`') + ->and($issueConfig) + ->toContain('Feature code should branch from `next` and target `next`') + ->toContain('Small fixes should target `main`; larger changes should target `next`'); +}); From ef0e7cfa0550d4fe82af8c0832e96e6c1324996a Mon Sep 17 00:00:00 2001 From: Yannick Seeger Date: Wed, 12 Aug 2026 13:33:53 +0200 Subject: [PATCH 10/95] fix(ui): drop the empty console canvas from terminal target selection Target selection now renders as a full-width layer card instead of a narrow dialog floating in an empty full-height console canvas. The themed console shell, its viewport height lock, and the console theme selector stay with an open session, where they belong. Co-Authored-By: Claude Opus 5 (1M context) --- DESIGN.md | 10 ++ resources/css/app.css | 80 ++++++++++++ .../views/livewire/terminal/index.blade.php | 123 +++++++++--------- .../Feature/RealtimeTerminalPackagingTest.php | 2 +- tests/Feature/TerminalPageHeaderTest.php | 67 +++++++--- 5 files changed, 204 insertions(+), 78 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5546b28f0..5c486ed70 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -619,6 +619,16 @@ ### Terminals 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/resources/css/app.css b/resources/css/app.css index 67562aa08..ed125c79e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -727,6 +727,86 @@ .terminal-target-picker { 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/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 8694aa0bf..a4a42bdfc 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 && $servers->isNotEmpty()) + +
- + class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" /> + +
- @endif -
-
- @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
-
-
+