From b85a9197a162b5b4d9d283e94a838e1b720fd066 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:10:39 +0100 Subject: [PATCH] chore: merge main into next (#12382) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .ai/lessons.md | 6 + app/Actions/Fortify/ResetUserPassword.php | 1 + .../Api/ApplicationsController.php | 29 ++ .../Controllers/Api/ServicesController.php | 3 - .../MatchesManualWebhookApplications.php | 8 +- app/Jobs/ApplicationDeploymentJob.php | 12 + app/Jobs/DockerCleanupJob.php | 42 ++- app/Jobs/ReconcileGithubRunnersJob.php | 24 +- app/Jobs/ScheduledJobManager.php | 3 +- app/Jobs/ServerManagerJob.php | 120 +++--- app/Livewire/Dashboard.php | 109 ++++++ app/Livewire/Dashboard/ActiveDeployments.php | 66 +++- app/Livewire/Project/Application/General.php | 9 + app/Livewire/Project/Application/Heading.php | 2 +- .../Project/Database/Postgresql/General.php | 16 +- .../Shared/EnvironmentVariable/All.php | 19 +- app/Livewire/Security/PrivateKey/Show.php | 1 - app/Livewire/Server/DockerImages.php | 55 +++ app/Livewire/Server/Resources.php | 47 ++- app/Livewire/Tags/Show.php | 36 ++ app/Livewire/Team/AuditLog.php | 28 ++ app/Livewire/Team/Invitations.php | 43 +++ app/Livewire/Team/InviteLink.php | 37 +- app/Models/Application.php | 50 ++- app/Models/ApplicationDeploymentQueue.php | 19 +- app/Models/ServerSetting.php | 16 + app/Models/User.php | 24 +- app/Services/TerminalSessionService.php | 3 +- app/Traits/DeletesUserSessions.php | 11 +- ..._interrupted_docker_cleanup_executions.php | 45 +++ ...ex_for_active_github_runner_executions.php | 41 ++ ...index_for_dashboard_recent_deployments.php | 41 ++ ...ndex_for_active_servers_by_private_key.php | 45 +++ openapi.json | 18 - openapi.yaml | 12 +- other/nightly/upgrade.sh | 38 ++ public/svgs/hi-events.svg | 20 + resources/css/app.css | 95 ++++- .../views/auth/two-factor-challenge.blade.php | 25 +- .../components/table/sort-header.blade.php | 71 ++++ resources/views/livewire/dashboard.blade.php | 58 ++- .../project/application/analytics.blade.php | 353 +++++++++--------- .../application/backup/index.blade.php | 49 ++- .../application/deployment/index.blade.php | 44 ++- .../project/application/general.blade.php | 4 +- .../views/livewire/project/index.blade.php | 43 ++- .../livewire/project/resource/index.blade.php | 64 ++-- .../service/volume-backup/index.blade.php | 59 ++- .../shared/environment-variable/all.blade.php | 3 +- .../views/livewire/project/show.blade.php | 44 ++- .../livewire/server/docker-images.blade.php | 20 +- .../views/livewire/server/resources.blade.php | 20 +- resources/views/livewire/tags/show.blade.php | 75 ++-- .../views/livewire/team/audit-log.blade.php | 14 +- .../views/livewire/team/invitations.blade.php | 14 +- .../views/livewire/team/member.blade.php | 8 +- .../livewire/team/member/index.blade.php | 39 +- scripts/upgrade.sh | 38 ++ templates/compose/hi-events.yaml | 89 +++++ templates/service-templates-latest.json | 20 + templates/service-templates.json | 20 + ...plicationWatchPathsFieldVisibilityTest.php | 104 ++++++ tests/Feature/AuditEventsTest.php | 39 ++ tests/Feature/Auth/ResetUserPasswordTest.php | 34 ++ tests/Feature/DashboardDeploymentsTest.php | 128 ++++++- tests/Feature/DashboardSortTest.php | 70 ++++ .../Feature/DeploymentHistoryPayloadTest.php | 181 +++++++-- tests/Feature/DeploymentsIndicatorTest.php | 28 ++ tests/Feature/DockerCleanupJobTest.php | 90 +++++ ...nmentVariableDevViewComposeDefaultTest.php | 70 ++++ ...nvironmentVariableDevViewDirectiveTest.php | 12 + .../PostgresqlInitScriptPathTraversalTest.php | 103 +++++ ...concileGithubRunnersDisabledConfigTest.php | 67 ++++ .../Security/PrivateKeyServerLookupTest.php | 184 +++++++++ .../ServerManagerJobServerSelectionTest.php | 135 +++++++ tests/Feature/ServerDockerImagesTest.php | 49 +++ tests/Feature/ServerResourcesSortTest.php | 97 +++++ tests/Feature/ServerSettingsBulkLoadTest.php | 118 ++++++ tests/Feature/SharedRegistryImageNameTest.php | 180 +++++++++ tests/Feature/TagsShowPageTest.php | 38 ++ tests/Feature/TeamInvitationHandlingTest.php | 82 ++++ tests/Feature/TeamInvitationUiTest.php | 29 ++ .../Feature/TeamMemberTwoFactorStatusTest.php | 18 + .../ManualWebhookApplicationLookupTest.php | 204 ++++++++++ tests/Unit/TerminalShellCommandTest.php | 36 ++ tests/Unit/UpgradeScriptRootUserEnvTest.php | 79 ++++ tests/v4/Browser/BackupSortTest.php | 4 + tests/v4/Browser/LoginTest.php | 22 ++ tests/v4/Browser/TrafficGeoCitiesTest.php | 24 ++ 89 files changed, 3988 insertions(+), 605 deletions(-) create mode 100644 database/migrations/2026_10_28_214953_add_index_for_interrupted_docker_cleanup_executions.php create mode 100644 database/migrations/2026_10_28_224219_add_index_for_active_github_runner_executions.php create mode 100644 database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php create mode 100644 database/migrations/2026_10_28_232000_add_index_for_active_servers_by_private_key.php create mode 100644 public/svgs/hi-events.svg create mode 100644 resources/views/components/table/sort-header.blade.php create mode 100644 templates/compose/hi-events.yaml create mode 100644 tests/Feature/ApplicationWatchPathsFieldVisibilityTest.php create mode 100644 tests/Feature/Auth/ResetUserPasswordTest.php create mode 100644 tests/Feature/DashboardSortTest.php create mode 100644 tests/Feature/EnvironmentVariableDevViewComposeDefaultTest.php create mode 100644 tests/Feature/PostgresqlInitScriptPathTraversalTest.php create mode 100644 tests/Feature/Security/PrivateKeyServerLookupTest.php create mode 100644 tests/Feature/Server/ServerManagerJobServerSelectionTest.php create mode 100644 tests/Feature/ServerResourcesSortTest.php create mode 100644 tests/Feature/ServerSettingsBulkLoadTest.php create mode 100644 tests/Feature/SharedRegistryImageNameTest.php create mode 100644 tests/Feature/Webhook/ManualWebhookApplicationLookupTest.php create mode 100644 tests/Unit/TerminalShellCommandTest.php create mode 100644 tests/Unit/UpgradeScriptRootUserEnvTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 23292f3729..84a4a3fc48 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -156,3 +156,9 @@ ## Validate input safely - In PCRE, `$` also matches before a final `\n`. In validation regexes, use `\z` or reject control characters first, or a trailing newline passes. + +## Application queries carry hidden counts +- `Application` has a global scope `withRelations` that adds two `withCount` subqueries and `applications.*`, also to `pluck('id')` and eager loads. For ID lookups and lean eager loads, call `withoutGlobalScope('withRelations')`. + +## Measure database reads end to end +- On PostgreSQL 15, `EXPLAIN (ANALYZE, BUFFERS)` does not detoast or send the selected columns, so a wide `jsonb`/`json`/`text` column does not show in it. Time the full fetch (`psql \timing` with `-o /dev/null`, or PDO `fetchAll`) as well before you blame or rule out an index. diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php index 5baa8b7ed2..d7a6e5a4a0 100644 --- a/app/Actions/Fortify/ResetUserPassword.php +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -23,6 +23,7 @@ class ResetUserPassword implements ResetsUserPasswords $user->fill([ 'password' => Hash::make($input['password']), + 'force_password_reset' => false, ])->save(); $user->deleteAllSessions(); } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8c19533688..e6851c76e6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -222,6 +222,19 @@ class ApplicationsController extends Controller ], 422); } + private function sharedImageNameValidationResponse(Application $application): ?JsonResponse + { + $otherApplication = $application->otherApplicationBuildingSameImage(); + if (! $otherApplication) { + return null; + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_registry_image_name' => [$application->sharedImageNameMessage($otherApplication)]], + ], 422); + } + private function applyApplicationSettings(Application $application, array $settings): void { if ($settings === []) { @@ -1687,6 +1700,9 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); if (isset($isStatic)) { @@ -1947,6 +1963,9 @@ class ApplicationsController extends Controller $application->source_id = $githubApp->id; $application->repository_project_id = $repository_project_id; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); @@ -2176,6 +2195,9 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); @@ -2324,6 +2346,9 @@ class ApplicationsController extends Controller $application->git_repository = 'coollabsio/coolify'; $application->git_branch = 'main'; + if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { + return $imageNameValidation; + } $application->save(); $this->applyApplicationSettings($application, $applicationSettings); $application->refresh(); @@ -3564,6 +3589,10 @@ class ApplicationsController extends Controller if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { return $prefixValidation; } + $requestedImage = (clone $application)->fill($request->only(['docker_registry_image_name', 'build_pack'])); + if ($requestedImage->isDirty(['docker_registry_image_name', 'build_pack']) && $imageNameValidation = $this->sharedImageNameValidationResponse($requestedImage)) { + return $imageNameValidation; + } if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) { return $internalNameValidation; } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 222baba002..389451351a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1487,7 +1487,6 @@ class ServicesController extends Controller properties: [ 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], - 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], @@ -1631,7 +1630,6 @@ class ServicesController extends Controller properties: [ 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], - 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], @@ -1764,7 +1762,6 @@ class ServicesController extends Controller properties: [ 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], - 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index decb6705fc..9fb910b49d 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -35,7 +35,13 @@ trait MatchesManualWebhookApplications */ protected function manualWebhookApplications(Builder $query, string $fullName): Collection { - return $query->get() + // Every repository form that manualWebhookRepositoryMatches() accepts + // contains the repository name, so the database discards unrelated + // applications that share the branch. The full match stays in PHP. + $pattern = '%'.str_replace('_', '\\_', mb_strtolower($fullName)).'%'; + + return $query->whereRaw("LOWER(git_repository) LIKE ? ESCAPE '\\'", [$pattern]) + ->get() ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName)) ->values(); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index d8910685a5..7f99781adf 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -370,6 +370,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { $this->validateDeploymentEnvironmentVariableKeys(); $this->ensureRegistryImageForMultipleServers(); + $this->ensureRegistryImageIsNotShared(); $this->warnAboutVolumesOnMultipleServers(); $this->warnAboutMixedProxiesOnMultipleServers(); } catch (Exception $e) { @@ -2590,6 +2591,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue throw new DeploymentException('Before deploying to multiple servers, you must set a Docker image in the General tab. More information: https://coolify.io/docs/knowledge-base/server/multiple-servers'); } + /** + * Two applications that build the same Docker image name overwrite each other's image tags. + */ + private function ensureRegistryImageIsNotShared(): void + { + $otherApplication = $this->application->otherApplicationBuildingSameImage(); + if ($otherApplication) { + throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication)); + } + } + /** * Build-time names go into shell and Docker build commands, so they must be valid when the * deployment builds an image ($buildsImage, checked right before the build steps). Restarts and diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index af1778eacf..3f70534301 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -13,6 +13,7 @@ use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -43,6 +44,8 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue public const INTERRUPTED_MESSAGE = 'Docker cleanup was interrupted: its worker stopped before the cleanup finished.'; + public const INTERRUPTED_EXECUTIONS_CHUNK_SIZE = 100; + /** * Shares the per-server lock with queued CleanupDocker runs, so two cleanups never run on * one server at once. A scheduled run that finds the lock held marks its occurrence @@ -254,31 +257,32 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue */ public static function failInterruptedExecutions(): void { - $executions = DockerCleanupExecution::query() + DockerCleanupExecution::query() + ->select(['id', 'server_id']) ->with('server.team') ->where('status', 'running') ->whereNull('finished_at') ->where('created_at', '<', now()->subSeconds(CleanupDocker::overlapLockExpiresAfter())) - ->get(); + ->chunkById(self::INTERRUPTED_EXECUTIONS_CHUNK_SIZE, function (Collection $executions): void { + foreach ($executions as $execution) { + $updated = DockerCleanupExecution::query() + ->whereKey($execution->id) + ->where('status', 'running') + ->whereNull('finished_at') + ->update([ + 'status' => 'failed', + 'message' => self::INTERRUPTED_MESSAGE, + 'finished_at' => Carbon::now()->toImmutable(), + ]); - foreach ($executions as $execution) { - $updated = DockerCleanupExecution::query() - ->whereKey($execution->id) - ->where('status', 'running') - ->whereNull('finished_at') - ->update([ - 'status' => 'failed', - 'message' => self::INTERRUPTED_MESSAGE, - 'finished_at' => Carbon::now()->toImmutable(), - ]); + if ($updated === 0) { + continue; + } - if ($updated === 0) { - continue; - } - - event(new DockerCleanupDone($execution->refresh())); - $execution->server?->team?->notify(new DockerCleanupFailed($execution->server, 'Docker cleanup job failed with the following error: '.self::INTERRUPTED_MESSAGE)); - } + event(new DockerCleanupDone($execution->refresh())); + $execution->server?->team?->notify(new DockerCleanupFailed($execution->server, 'Docker cleanup job failed with the following error: '.self::INTERRUPTED_MESSAGE)); + } + }); } public function failed(?\Throwable $exception): void diff --git a/app/Jobs/ReconcileGithubRunnersJob.php b/app/Jobs/ReconcileGithubRunnersJob.php index f3adb513c4..e3447c87aa 100644 --- a/app/Jobs/ReconcileGithubRunnersJob.php +++ b/app/Jobs/ReconcileGithubRunnersJob.php @@ -43,11 +43,7 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue public function handle(): void { - // Disabling keeps running jobs, so a disabled server is reconciled until its last runner is done. - Server::query() - ->where(fn (Builder $query) => $query - ->whereHas('githubRunnerConfig', fn (Builder $config) => $config->where('is_enabled', true)) - ->orWhereHas('githubRunnerExecutions', fn (Builder $execution) => $execution->whereIn('status', GithubRunnerStatus::occupying()))) + $this->serversToReconcile() ->with('settings') ->get() ->filter(fn (Server $server) => $server->isFunctional()) @@ -57,6 +53,24 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue $this->reconcileQueued(); } + /** + * Servers with enabled runners or with executions that still occupy a runner. Disabling keeps + * running jobs, so a disabled server is reconciled until its last runner is done. The server ids + * come from one UNION: an OR of two EXISTS subqueries makes Postgres estimate a per-server scan + * of the executions and spend most of the query time on JIT compilation. + * + * @return Builder + */ + public function serversToReconcile(): Builder + { + return Server::query()->whereIn('id', GithubRunnerConfig::query() + ->select('server_id') + ->where('is_enabled', true) + ->union(GithubRunnerExecution::query() + ->select('server_id') + ->whereIn('status', GithubRunnerStatus::occupying()))); + } + /** * Removes executions that still occupy a runner of a deleted server (for example a server deleted * without model events), because they block deleting the GitHub App. diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 7e137dadaa..48b4b0dae1 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -17,6 +17,7 @@ use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -741,7 +742,7 @@ class ScheduledJobManager implements ShouldQueue private function getServersForCleanupQuery(): Builder { - $query = Server::with('settings') + $query = Server::with(['settings' => fn (HasOne $query) => $query->withoutPatchCheckResult()]) ->whereNotNull('ip') ->where('ip', '!=', '') ->whereNotIn('ip', Server::PLACEHOLDER_IPS); diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 815bb09361..0d93b84d6b 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -4,10 +4,10 @@ namespace App\Jobs; use App\Models\InstanceSettings; use App\Models\Server; -use App\Models\Team; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -37,6 +37,11 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue */ private const CLOUD_PROVIDER_STATUS_CHECK_CRON = '*/5 * * * *'; + /** + * Number of servers loaded and processed at a time. + */ + public const SERVER_BATCH_SIZE = 500; + /** * Create a new job instance. */ @@ -59,43 +64,55 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue $this->instanceTimezone = config('app.timezone'); } - // Get all servers to process - $servers = $this->getServers(); + // Decide once per run: these cron keys are global, so a second call in the same run returns false. + $runCloudProviderStatusChecks = shouldRunCronNow(self::CLOUD_PROVIDER_STATUS_CHECK_CRON, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime); + $runConnectionChecks = shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime); - // Provider state checks run independently so slow APIs cannot block SSH checks. - $this->dispatchCloudProviderStatusChecks($servers); + $relations = ['settings' => fn (HasOne $query) => $query->withoutPatchCheckResult(), 'cloudProviderToken']; - $servers = $servers - ->reject(fn (Server $server) => $server->hasPlaceholderIp()) - ->values(); + foreach ($this->getServerIds()->chunk(self::SERVER_BATCH_SIZE) as $serverIds) { + $servers = Server::with($relations)->whereKey($serverIds)->orderBy('id')->get(); - // Dispatch ServerConnectionCheck for all servers efficiently - $this->dispatchConnectionChecks($servers); + // Provider state checks run independently so slow APIs cannot block SSH checks. + if ($runCloudProviderStatusChecks) { + $this->dispatchCloudProviderStatusChecks($servers); + } - // Process server-specific scheduled tasks - $this->processScheduledTasks($servers); + $servers = $servers + ->reject(fn (Server $server) => $server->hasPlaceholderIp()) + ->values(); + + if ($runConnectionChecks) { + $this->dispatchConnectionChecks($servers); + } + + // Process server-specific scheduled tasks + $this->processScheduledTasks($servers); + } } - private function getServers(): Collection + /** + * Selects only the IDs, so the paid-subscription filter runs once and the full server rows are loaded + * in batches by primary key. Loading every server in one query kept all of them in worker memory. + * toBase() skips model hydration, which an Eloquent pluck of the cast id column would do for every row. + * + * @return Collection + */ + private function getServerIds(): Collection { - $allServers = Server::with(['settings', 'cloudProviderToken']); - - if (isCloud()) { - $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); - $own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get(); - - return $servers->merge($own)->unique('id')->values(); - } else { - return $allServers->get(); + if (! isCloud()) { + return Server::query()->orderBy('id')->toBase()->pluck('id'); } + + return Server::query()->whereRelation('team.subscription', 'stripe_invoice_paid', true)->toBase()->pluck('id') + ->merge(Server::query()->where('team_id', 0)->toBase()->pluck('id')) + ->unique() + ->sort() + ->values(); } private function dispatchCloudProviderStatusChecks(Collection $servers): void { - if (! shouldRunCronNow(self::CLOUD_PROVIDER_STATUS_CHECK_CRON, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) { - return; - } - $servers->each(function (Server $server) { $hasCloudResource = $server->hetzner_server_id || $server->vultr_instance_id @@ -110,34 +127,31 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue private function dispatchConnectionChecks(Collection $servers): void { - - if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) { - $servers->each(function (Server $server) { - try { - if ($server->hasPlaceholderIp()) { - return; - } - - // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity. - // Never skip while the server is marked unreachable or unusable: the heartbeat does not - // restore these flags, so deployments would fail with "Server is not functional" forever. - if ($server->isSentinelEnabled() && $server->isSentinelLive() - && $server->settings->is_reachable && $server->settings->is_usable) { - return; - } - if ($this->shouldSkipDueToBackoff($server)) { - return; - } - ServerConnectionCheckJob::dispatch($server); - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'error' => get_class($e).': '.$e->getMessage(), - ]); + $servers->each(function (Server $server) { + try { + if ($server->hasPlaceholderIp()) { + return; } - }); - } + + // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity. + // Never skip while the server is marked unreachable or unusable: the heartbeat does not + // restore these flags, so deployments would fail with "Server is not functional" forever. + if ($server->isSentinelEnabled() && $server->isSentinelLive() + && $server->settings->is_reachable && $server->settings->is_usable) { + return; + } + if ($this->shouldSkipDueToBackoff($server)) { + return; + } + ServerConnectionCheckJob::dispatch($server); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => get_class($e).': '.$e->getMessage(), + ]); + } + }); } private function processScheduledTasks(Collection $servers): void diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index f04c960345..c542342b6c 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -16,12 +16,25 @@ class Dashboard extends Component { use RemembersCanvasView; + /** The dashboard shows only the first projects and servers of the sorted lists. */ + public const ITEM_LIMIT = 8; + + private const PROJECT_SORT_COLUMNS = ['name', 'resources', 'description']; + + private const SERVER_SORT_COLUMNS = ['name', 'description', 'status']; + public Collection $projects; public Collection $servers; public Collection $privateKeys; + #[Locked] + public string $projectSort = 'name-asc'; + + #[Locked] + public string $serverSort = 'name-asc'; + /** * Members limited to some projects do not see team-level sections such as servers. */ @@ -60,11 +73,107 @@ class Dashboard extends Component public function render() { + $dashboardProjects = $this->sortedProjects()->take(self::ITEM_LIMIT); + $dashboardServers = $this->sortedServers()->take(self::ITEM_LIMIT); + return view('livewire.dashboard', [ 'pendingInvitations' => $this->pendingInvitations(), + 'dashboardProjects' => $dashboardProjects, + 'dashboardServers' => $dashboardServers, + 'projectResourceCounts' => $dashboardProjects->mapWithKeys(fn (Project $project) => [$project->id => $this->projectResourceCount($project)]), + 'serverStatuses' => $dashboardServers->mapWithKeys(fn (Server $server) => [$server->id => $this->serverStatus($server)]), ]); } + public function setProjectSort(string $sort): void + { + if ($this->isValidSort($sort, self::PROJECT_SORT_COLUMNS)) { + $this->projectSort = $sort; + } + } + + public function setServerSort(string $sort): void + { + if ($this->isValidSort($sort, self::SERVER_SORT_COLUMNS)) { + $this->serverSort = $sort; + } + } + + private function projectResourceCount(Project $project): int + { + return $project->applications_count + $project->services_count + $project->postgresqls_count + + $project->redis_count + $project->keydbs_count + $project->dragonflies_count + + $project->clickhouses_count + $project->mongodbs_count + $project->mysqls_count + + $project->mariadbs_count + $project->sqlites_count; + } + + /** + * @return array{0: string, 1: 'success'|'warning'|'error'} label and badge type + */ + private function serverStatus(Server $server): array + { + $proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); + $sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'; + + return 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'], + }; + } + + /** + * @return Collection + */ + private function sortedProjects(): Collection + { + return $this->sortItems($this->projects, $this->projectSort, fn (Project $project, string $column): int|string => match ($column) { + 'resources' => $this->projectResourceCount($project), + 'description' => (string) $project->description, + default => $project->name, + }); + } + + /** + * @return Collection + */ + private function sortedServers(): Collection + { + return $this->sortItems($this->servers, $this->serverSort, fn (Server $server, string $column): string => match ($column) { + 'description' => (string) $server->description, + 'status' => $this->serverStatus($server)[0], + default => $server->name, + }); + } + + /** + * Sorts by the column of a `column-direction` sort value, with the name as tie-breaker. + * + * @param callable(mixed, string): (int|string) $value + */ + private function sortItems(Collection $items, string $sort, callable $value): Collection + { + [$column, $direction] = explode('-', $sort, 2); + + return $items + ->sortBy(fn ($item) => $item->name, SORT_NATURAL | SORT_FLAG_CASE) + ->sortBy(fn ($item) => $value($item, $column), SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc') + ->values(); + } + + /** + * @param list $columns + */ + private function isValidSort(string $sort, array $columns): bool + { + [$column, $direction] = array_pad(explode('-', $sort, 2), 2, null); + + return in_array($column, $columns, true) && in_array($direction, ['asc', 'desc'], true); + } + /** * Unexpired invitations for the user's email to teams the user has not joined yet. * diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php index e3f9ab21e1..1af9de56a8 100644 --- a/app/Livewire/Dashboard/ActiveDeployments.php +++ b/app/Livewire/Dashboard/ActiveDeployments.php @@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus; use App\Models\ApplicationDeploymentQueue; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Computed; use Livewire\Component; @@ -28,6 +29,18 @@ class ActiveDeployments extends Component 'finished_at', ]; + private const ACTIVE_STATUSES = [ + ApplicationDeploymentStatus::IN_PROGRESS->value, + ApplicationDeploymentStatus::QUEUED->value, + ]; + + /** + * Not dehydrated, so it is loaded once per request and shared by both lists. + * + * @var Collection|null + */ + private ?Collection $teamApplicationIds = null; + /** * Polling target. The lists are loaded again on every render. */ @@ -37,10 +50,8 @@ class ActiveDeployments extends Component public function activeDeployments(): Collection { return $this->deploymentsQuery() - ->whereIn('status', [ - ApplicationDeploymentStatus::IN_PROGRESS->value, - ApplicationDeploymentStatus::QUEUED->value, - ]) + ->whereIn('application_id', $this->teamApplicationIds()) + ->whereIn('status', self::ACTIVE_STATUSES) ->orderBy('status') ->orderBy('id') ->limit(5) @@ -50,22 +61,49 @@ class ActiveDeployments extends Component #[Computed] public function recentDeployments(): Collection { - return $this->deploymentsQuery() - ->whereNotIn('status', [ - ApplicationDeploymentStatus::IN_PROGRESS->value, - ApplicationDeploymentStatus::QUEUED->value, - ]) + if (DB::connection()->getDriverName() !== 'pgsql') { + return $this->deploymentsQuery() + ->whereIn('application_id', $this->teamApplicationIds()) + ->whereNotIn('status', self::ACTIVE_STATUSES) + ->orderByDesc('id') + ->limit(5) + ->get(self::COLUMNS); + } + + // With "application_id IN (...) ORDER BY id DESC" PostgreSQL scans the primary key backwards and can + // read most of the table for a team without recent deployments. Take the five newest of each + // application from idx_deployment_queues_app_id instead; the five newest of the team are among them. + $newestPerApplication = ApplicationDeploymentQueue::query() + ->select(self::COLUMNS) + ->whereColumn('application_id', 'team_applications.id') + ->whereNotIn('status', self::ACTIVE_STATUSES) ->orderByDesc('id') + ->limit(5); + + return $this->deploymentsQuery() + ->fromRaw('unnest(?::varchar[]) as team_applications(id)', ['{'.$this->teamApplicationIds()->implode(',').'}']) + ->joinLateral($newestPerApplication, 'application_deployment_queues') + ->orderByDesc('application_deployment_queues.id') ->limit(5) - ->get(self::COLUMNS); + ->get(array_map(fn (string $column) => "application_deployment_queues.{$column}", self::COLUMNS)); } private function deploymentsQuery(): Builder { - return ApplicationDeploymentQueue::query() - ->with(['application.environment.project']) - ->ownedByTeam(currentTeam()->id) - ->visibleToCurrentActor(); + return ApplicationDeploymentQueue::query()->with([ + 'application' => fn ($query) => $query->withoutGlobalScope('withRelations')->select(['id', 'environment_id']), + 'application.environment.project', + ]); + } + + /** + * The Application access grant scope also limits the IDs to the applications the current actor can see. + * + * @return Collection + */ + private function teamApplicationIds(): Collection + { + return $this->teamApplicationIds ??= ApplicationDeploymentQueue::applicationIdsOwnedByTeam(currentTeam()->id); } public function render() diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 0069a1c21a..c7a888a1cd 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -871,6 +871,15 @@ class General extends Component $this->syncData(toModel: true); + $otherApplication = $this->application->isDirty(['docker_registry_image_name', 'build_pack']) + ? $this->application->otherApplicationBuildingSameImage() + : null; + if ($otherApplication) { + $this->addError('dockerRegistryImageName', $this->application->sharedImageNameMessage($otherApplication)); + + return; + } + if ($this->application->isDirty('redirect')) { $this->setRedirect(); } diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 9b40240c19..182f96ae75 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -61,7 +61,7 @@ class Heading extends Component 'environment_uuid' => $this->application->environment->uuid, 'application_uuid' => $this->application->uuid, ]; - $lastDeployment = $this->application->get_last_successful_deployment(); + $lastDeployment = $this->application->get_last_successful_deployment(['commit', 'commit_message']); $this->lastDeploymentInfo = data_get_str($lastDeployment, 'commit')->limit(7).' '.data_get($lastDeployment, 'commit_message'); $this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit')); $this->loadComposeOnOpen = $this->activeRouteName === 'project.application.configuration' diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index ee546680f0..0b5dd1ec83 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -9,6 +9,7 @@ use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class General extends Component @@ -35,6 +36,7 @@ class General extends Component public ?string $postgresConf = null; + #[Locked] public ?array $initScripts = null; public string $image; @@ -265,11 +267,19 @@ class General extends Component $container_name = $this->database->uuid; $configuration_dir = database_configuration_dir().'/'.$container_name; + // Only an unchanged filename of an existing script skips validation, so legacy rows stay editable. + if (! $oldScript || $oldScript['filename'] !== $script['filename']) { + try { + validateFilenameSafe((string) data_get($script, 'filename'), 'init script filename'); + } catch (Exception $e) { + $this->dispatch('error', $e->getMessage()); + + return; + } + } + if ($oldScript && $oldScript['filename'] !== $script['filename']) { try { - // New filename is user-supplied — must be safe before accepting the rename. - validateFilenameSafe($script['filename'], 'init script filename'); - // Old filename may be a legacy value written before this validation existed. // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains // any remaining shell-metachars. No validator — don't block cleanup of legacy rows. diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 37f3f1b59a..09f26e725e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -990,10 +990,27 @@ class All extends Component return $variables ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) - ->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues)) + ->map(fn ($item) => $this->composeDefaultHint($item).formatEnvVariableForDevView($item, $canViewValues)) ->join("\n"); } + /** + * An empty value makes the deployment use the Compose default, so show that default above the line. + */ + private function composeDefaultHint(EnvironmentVariable $variable): string + { + if (filled($variable->value) || ! $this->showsHardcodedEnvironmentVariables()) { + return ''; + } + + $default = $this->composeInfoFor($variable->key)['default'] ?? null; + if (blank($default) || str_contains($default, "\n")) { + return ''; + } + + return "# Compose default: {$default}\n"; + } + public function switch() { $this->authorize('view', $this->resource); diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 1b8f26ff28..1c9277d116 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -142,7 +142,6 @@ class Show extends Component $this->private_key->updatePrivateKey([ 'private_key' => formatPrivateKey($this->private_key->private_key), ]); - refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); if ($this->modalMode) { $this->dispatch('privateKeyUpdated'); diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php index 641a686851..f5909333db 100644 --- a/app/Livewire/Server/DockerImages.php +++ b/app/Livewire/Server/DockerImages.php @@ -32,6 +32,11 @@ class DockerImages extends Component public int $perPage = 10; + /** + * Column sort such as "size_desc". Empty keeps the order of `docker image ls` (newest first). + */ + public string $sort = ''; + public function updatedSearch(): void { $this->resetPage(); @@ -43,6 +48,32 @@ class DockerImages extends Component $this->resetPage(); } + public function setSort(string $sort): void + { + if (! array_key_exists(str($sort)->beforeLast('_')->value(), $this->sortColumns()) + || ! in_array(str($sort)->afterLast('_')->value(), ['asc', 'desc'], true)) { + return; + } + + $this->sort = $sort; + $this->resetPage(); + } + + /** + * @return array + */ + private function sortColumns(): array + { + return [ + 'repository' => fn (array $image) => $image['repository'].':'.$image['tag'], + 'tag' => fn (array $image) => $image['tag'], + 'id' => fn (array $image) => $image['short_id'], + 'created' => fn (array $image) => $image['created_at'] ?? 0, + 'size' => fn (array $image) => $image['size_bytes'] ?? 0, + 'used' => fn (array $image) => implode(', ', $image['containers']), + ]; + } + public function mount(string $server_uuid) { try { @@ -161,11 +192,27 @@ class DockerImages extends Component // removed by ID, Docker refuses with "image is referenced in multiple repositories". 'reference' => $isDangling ? $id : "{$repository}:{$tag}", 'size' => data_get($image, 'Size'), + 'size_bytes' => $this->sizeToBytes((string) data_get($image, 'Size')), 'created' => data_get($image, 'CreatedSince'), + // Docker prints "2024-05-01 10:00:00 +0000 UTC"; the trailing zone name breaks strtotime(). + 'created_at' => strtotime(str((string) data_get($image, 'CreatedAt'))->replaceMatches('/ [A-Z]+$/', '')->value()) ?: 0, 'containers' => $containersByImage[$id] ?? [], ]; } + /** + * Docker prints sizes with decimal units, for example "512kB", "52.5MB" or "1.2GB". + */ + private function sizeToBytes(string $size): int + { + if (! preg_match('/^([\d.]+)\s*([kKMGTP]?)B$/', trim($size), $matches)) { + return 0; + } + $exponent = strpos('KMGTP', strtoupper($matches[2])); + + return (int) round((float) $matches[1] * 1000 ** ($matches[2] === '' ? 0 : $exponent + 1)); + } + public function render() { $search = trim($this->search); @@ -175,6 +222,14 @@ class DockerImages extends Component ->contains($search, ignoreCase: true)); } + $column = $this->sortColumns()[str($this->sort)->beforeLast('_')->value()] ?? null; + if ($column) { + $images = $images->sortBy( + fn (array $image) => [$column($image), $image['reference']], + descending: str($this->sort)->endsWith('_desc'), + ); + } + $this->perPage = max(1, min(100, $this->perPage)); $lastPage = max(1, (int) ceil($images->count() / $this->perPage)); $page = max(1, min((int) $this->getPage(), $lastPage)); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 86f87cc13f..130d11eeea 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -22,6 +22,8 @@ class Resources extends Component public string $search = ''; + public string $sort = 'name_asc'; + public function updatedSearch(): void { $this->resetPage(); @@ -33,6 +35,38 @@ class Resources extends Component $this->resetPage(); } + public function setSort(string $sort): void + { + if (! preg_match('/^(\w+)_(asc|desc)$/', $sort, $matches) || ! array_key_exists($matches[1], $this->sortColumns())) { + return; + } + + $this->sort = $sort; + $this->resetPage(); + } + + /** + * @return array + */ + private function sortColumns(): array + { + if ($this->activeTab === 'managed') { + return [ + 'name' => fn ($resource): string => (string) data_get($resource, 'name'), + 'project' => fn ($resource): string => (string) data_get($resource->project(), 'name'), + 'environment' => fn ($resource): string => (string) data_get($resource, 'environment.name'), + 'type' => fn ($resource): string => (string) $resource->type(), + 'status' => fn ($resource): string => (string) data_get($resource, 'status'), + ]; + } + + return [ + 'name' => fn ($container): string => (string) data_get($container, 'Names'), + 'image' => fn ($container): string => (string) data_get($container, 'Image'), + 'status' => fn ($container): string => (string) data_get($container, 'State'), + ]; + } + public ?Server $server = null; public $parameters = []; @@ -115,6 +149,7 @@ class Resources extends Component try { if ($this->activeTab !== 'managed') { $this->search = ''; + $this->sort = 'name_asc'; $this->resetPage(); } $this->activeTab = 'managed'; @@ -128,6 +163,7 @@ class Resources extends Component { if ($this->activeTab !== 'unmanaged') { $this->search = ''; + $this->sort = 'name_asc'; $this->resetPage(); } $this->activeTab = 'unmanaged'; @@ -156,9 +192,14 @@ class Resources extends Component public function render() { - $resources = $this->activeTab === 'managed' - ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) - : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $columns = $this->sortColumns(); + [$column, $direction] = explode('_', $this->sort, 2) + [1 => 'asc']; + $sortValue = $columns[$column] ?? $columns['name']; + $resources = ($this->activeTab === 'managed' + ? $this->server->definedResources() + : collect($this->unmanagedContainers)) + ->sortBy($columns['name'], SORT_NATURAL | SORT_FLAG_CASE) + ->sortBy($sortValue, SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc'); $search = trim($this->search); if ($search !== '') { $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index 01450abe9a..e0627d418c 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -42,6 +42,10 @@ class Show extends Component #[Locked] public ?array $deploymentsPerTagPerServer = null; + /** Sort of the active deployments table, such as `resource-asc`; null keeps them grouped by server. */ + #[Locked] + public ?string $deploymentSort = null; + /** The "Connect to resources" modal on small screens, where the tags canvas is not shown. */ public bool $connectResourcesOpen = false; @@ -103,6 +107,37 @@ class Show extends Component } } + public function setDeploymentSort(string $sort): void + { + if (! in_array($sort, ['resource-asc', 'resource-desc', 'server-asc', 'server-desc', 'status-asc', 'status-desc'], true)) { + return; + } + + $this->deploymentSort = $sort; + } + + /** + * The active deployments as one list, in the order of the chosen table sort. + * + * @return Collection> + */ + private function activeDeployments(): Collection + { + $deployments = collect($this->deploymentsPerTagPerServer ?? [])->flatten(1); + if ($this->deploymentSort === null) { + return $deployments->values(); + } + + [$column, $direction] = explode('-', $this->deploymentSort, 2); + $field = ['resource' => 'application_name', 'server' => 'server_name', 'status' => 'status'][$column]; + $order = $direction === 'desc' ? -1 : 1; + + return $deployments + ->sort(fn (array $left, array $right): int => ($order * strcasecmp((string) $left[$field], (string) $right[$field])) + ?: $left['id'] <=> $right['id']) + ->values(); + } + public function redeployAll() { try { @@ -193,6 +228,7 @@ class Show extends Component ]; })->values()->toArray(), 'connectableResources' => $this->connectableResources(), + 'activeDeployments' => $this->activeDeployments(), ]); } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php index 9cf3ada8c8..88a6ae2800 100644 --- a/app/Livewire/Team/AuditLog.php +++ b/app/Livewire/Team/AuditLog.php @@ -5,6 +5,7 @@ namespace App\Livewire\Team; use App\Models\AuditEvent; use Illuminate\Contracts\View\View; use Illuminate\Support\Str; +use Livewire\Attributes\Locked; use Livewire\Component; use Livewire\WithPagination; @@ -20,6 +21,17 @@ class AuditLog extends Component public int $perPage = 25; + #[Locked] + public string $sort = 'time-desc'; + + /** @var array sort value prefix => audit_events column */ + private const SORT_COLUMNS = [ + 'time' => 'created_at', + 'actor' => 'actor_name', + 'activity' => 'description', + 'source' => 'source', + ]; + public function boot(): void { $team = currentTeam(); @@ -42,6 +54,18 @@ class AuditLog extends Component $this->resetPage(); } + public function setSort(string $sort): void + { + [$column, $direction] = array_pad(explode('-', $sort, 2), 2, null); + + if (! array_key_exists($column, self::SORT_COLUMNS) || ! in_array($direction, ['asc', 'desc'], true)) { + return; + } + + $this->sort = $sort; + $this->resetPage(); + } + public function updatedPerPage(): void { $this->perPage = max(10, min(100, $this->perPage)); @@ -67,6 +91,10 @@ class AuditLog extends Component $events = AuditEvent::query() ->visibleToTeam($teamId, $canViewInstanceEvents) ->filtered($search, $this->action, $this->source) + ->when($this->sort !== 'time-desc', function ($query): void { + [$column, $direction] = explode('-', $this->sort, 2); + $query->orderBy(self::SORT_COLUMNS[$column], $direction); + }) ->latestFirst() ->paginate($this->perPage); diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8550afd9e3..3de396bb13 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -2,10 +2,13 @@ namespace App\Livewire\Team; +use App\Enums\Role; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\Locked; use Livewire\Component; class Invitations extends Component @@ -14,6 +17,10 @@ class Invitations extends Component public $invitations; + /** Sort value such as `email-asc`; null keeps the loaded order. */ + #[Locked] + public ?string $sort = null; + protected $listeners = ['refreshInvitations']; public function deleteInvitation(int $invitation_id) @@ -43,6 +50,42 @@ class Invitations extends Component } } + public function setSort(string $sort): void + { + if (! in_array($sort, ['email-asc', 'email-desc', 'via-asc', 'via-desc', 'role-asc', 'role-desc'], true)) { + return; + } + + $this->sort = $sort; + } + + public function render() + { + return view('livewire.team.invitations', [ + 'sortedInvitations' => $this->sortedInvitations(), + ]); + } + + private function sortedInvitations(): Collection + { + $invitations = collect($this->invitations); + if ($this->sort === null) { + return $invitations; + } + + [$column, $direction] = explode('-', $this->sort, 2); + $sortValue = fn (TeamInvitation $invitation): int|string => $column === 'role' + ? (Role::tryFrom((string) $invitation->role)?->rank() ?? 0) + : strtolower((string) $invitation->{$column}); + + $order = $direction === 'desc' ? -1 : 1; + + return $invitations + ->sort(fn (TeamInvitation $left, TeamInvitation $right): int => ($order * ($sortValue($left) <=> $sortValue($right))) + ?: strcasecmp((string) $left->email, (string) $right->email)) + ->values(); + } + public function refreshInvitations() { $this->invitations = TeamInvitation::ownedByCurrentTeam()->get(); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 61efff39a9..08c913ee2e 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -70,21 +70,6 @@ class InviteLink extends Component if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } - $uuid = new_public_id(32); - $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); - $user = User::whereEmail($this->email)->first(); - - if (is_null($user)) { - $password = Str::password(); - $user = User::create([ - 'name' => str($this->email)->before('@'), - 'email' => $this->email, - 'password' => Hash::make($password), - 'force_password_reset' => true, - ]); - $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); - $link = $this->invitationUrl('auth.link', ['token' => $token]); - } $invitation = TeamInvitation::ownedByCurrentTeam()->whereEmail($this->email)->first(); if (! is_null($invitation)) { $invitationValid = $invitation->isValid(); @@ -95,6 +80,28 @@ class InviteLink extends Component } } + // Look up the user after the expired invitation is removed, because that can delete an unused account. + $uuid = new_public_id(32); + $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); + $user = User::whereEmail($this->email)->first(); + + // A user who never set a password cannot log in, so they get a magic link with a new temporary password. + if (is_null($user) || $user->isUnusedInvitedAccount()) { + $password = Str::password(); + if (is_null($user)) { + $user = User::create([ + 'name' => str($this->email)->before('@'), + 'email' => $this->email, + 'password' => Hash::make($password), + 'force_password_reset' => true, + ]); + } else { + $user->forceFill(['password' => Hash::make($password)])->save(); + } + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); + $link = $this->invitationUrl('auth.link', ['token' => $token]); + } + $invitation = TeamInvitation::firstOrCreate([ 'team_id' => currentTeam()->id, 'uuid' => $uuid, diff --git a/app/Models/Application.php b/app/Models/Application.php index 5b737db56a..6add0a4e20 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -712,13 +712,13 @@ class Application extends BaseModel return false; } - public function is_github_based(): bool + /** + * Watch paths filter Git webhook deployments. Deploy-key applications have no + * source but receive manual webhooks, so a source is not required. + */ + public function supportsWatchPaths(): bool { - if (data_get($this, 'source')) { - return true; - } - - return false; + return $this->git_based() && ! $this->is_public_repository(); } public function isGithubAppSource(): bool @@ -1113,6 +1113,30 @@ class Application extends BaseModel return data_get($this, 'environment.project.team'); } + /** + * Another application of the team that builds and pushes an image with the same Docker image name. + * Both push the same tags (the commit SHA, `latest`, `pr-`), so one application can run the image of the other. + */ + public function otherApplicationBuildingSameImage(): ?Application + { + $buildPacksWithoutPushedImage = ['dockerimage', 'dockercompose']; + if (blank($this->docker_registry_image_name) || in_array($this->build_pack, $buildPacksWithoutPushedImage, true)) { + return null; + } + + return Application::query() + ->whereRelation('environment.project.team', 'id', $this->team()->id) + ->when($this->exists, fn ($query) => $query->whereKeyNot($this->id)) + ->where('docker_registry_image_name', $this->docker_registry_image_name) + ->whereNotIn('build_pack', $buildPacksWithoutPushedImage) + ->first(); + } + + public function sharedImageNameMessage(Application $otherApplication): string + { + return "The application {$otherApplication->name} also builds the Docker image {$this->docker_registry_image_name}. Both push the same image tags, so one application can run the image of the other. Set a different Docker image name for each application in the General settings."; + } + public function serviceType() { $found = str(collect(SPECIFIC_SERVICES)->filter(function ($service) { @@ -1222,15 +1246,7 @@ class Application extends BaseModel public function environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->orderByRaw(" - CASE - WHEN is_required = true THEN 1 - WHEN LOWER(key) LIKE 'service_%' THEN 2 - ELSE 3 - END, - LOWER(key) ASC - "); + ->where('is_preview', true); } public function runtime_environment_variables_preview() @@ -1299,9 +1315,9 @@ class Application extends BaseModel return false; } - public function get_last_successful_deployment() + public function get_last_successful_deployment(array $columns = ['*']) { - return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first(); + return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first($columns); } public function get_last_days_deployments() diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 8d8f92e5dc..e7c7f69cb8 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use OpenApi\Attributes as OA; @@ -181,13 +182,23 @@ class ApplicationDeploymentQueue extends Model */ public function scopeOwnedByTeam(Builder $query, int $teamId): Builder { - // application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL. - $applicationIds = Application::withTrashed() + return $query->whereIn('application_id', self::applicationIdsOwnedByTeam($teamId)); + } + + /** + * IDs of the team's applications, deleted ones included, as strings to match application_id. + * + * application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL. + * + * @return Collection + */ + public static function applicationIdsOwnedByTeam(int $teamId): Collection + { + return Application::withTrashed() + ->withoutGlobalScope('withRelations') ->whereRelation('environment.project.team', 'id', $teamId) ->pluck('id') ->map(fn ($id) => (string) $id); - - return $query->whereIn('application_id', $applicationIds); } public function server(): Attribute diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f251b7fd5a..ae32835c10 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -5,10 +5,12 @@ namespace App\Models; use App\Enums\ServerRole; use App\Enums\TrafficIpMode; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; use OpenApi\Attributes as OA; #[OA\Schema( @@ -231,6 +233,9 @@ class ServerSetting extends Model 'geoip_maxmind_license_key', ]; + /** @var list|null */ + private static ?array $columnsWithoutPatchCheckResult = null; + protected static function booted() { static::creating(function ($setting) { @@ -465,6 +470,17 @@ class ServerSetting extends Model ); } + /** + * Selects every column except patch_check_result, which holds the full update list of the last patch + * check (often tens of kilobytes). Use it for instance-wide server loops that do not read the result. + */ + public function scopeWithoutPatchCheckResult(Builder $query): Builder + { + self::$columnsWithoutPatchCheckResult ??= array_values(array_diff(Schema::getColumnListing($this->getTable()), ['patch_check_result'])); + + return $query->select(self::$columnsWithoutPatchCheckResult); + } + /** * Summary of the saved patch check, or null when the server was never checked. * Results saved before security and reboot detection count as 0 and false. diff --git a/app/Models/User.php b/app/Models/User.php index a195a0d05c..b771662f5d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -219,19 +219,21 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail */ public function deleteIfNotVerifiedAndForcePasswordReset(): void { - if ($this->hasVerifiedEmail() || $this->force_password_reset !== true) { - return; + if ($this->isUnusedInvitedAccount()) { + $this->delete(); } + } - if (TeamInvitation::whereEmail($this->email)->exists()) { - return; - } - - if ($this->belongsToNonPersonalTeam()) { - return; - } - - $this->delete(); + /** + * Whether an invitation created this account and nobody uses it: the owner never set a password, + * it has no pending invitations, and it belongs to no team other than its own personal team. + */ + public function isUnusedInvitedAccount(): bool + { + return ! $this->hasVerifiedEmail() + && $this->force_password_reset === true + && ! TeamInvitation::whereEmail($this->email)->exists() + && ! $this->belongsToNonPersonalTeam(); } /** diff --git a/app/Services/TerminalSessionService.php b/app/Services/TerminalSessionService.php index b2fb4d09a2..b1f113ca7b 100644 --- a/app/Services/TerminalSessionService.php +++ b/app/Services/TerminalSessionService.php @@ -109,7 +109,8 @@ class TerminalSessionService { return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. - 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; + 'if [ -n "$SHELL" ] && [ -x "$SHELL" ] && [ "${SHELL##*/}" != nologin ] && [ "${SHELL##*/}" != false ]; then exec $SHELL; '. + 'elif command -v bash >/dev/null 2>&1; then exec bash; else sh; fi'; } private function cacheKey(string $token): string diff --git a/app/Traits/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php index 44ff5f727d..32e2155052 100644 --- a/app/Traits/DeletesUserSessions.php +++ b/app/Traits/DeletesUserSessions.php @@ -3,20 +3,23 @@ namespace App\Traits; use App\Actions\User\RevokeUserTeamTokens; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; trait DeletesUserSessions { /** - * Delete all sessions for the current user. + * Delete all sessions for this user. * This will force the user to log in again on all devices. */ public function deleteAllSessions(): void { - // Invalidate the current session - Session::invalidate(); - Session::regenerateToken(); + // Invalidate the current session only when it belongs to this user, not to an admin who changes it + if (Auth::id() === $this->id) { + Session::invalidate(); + Session::regenerateToken(); + } DB::table('sessions')->where('user_id', $this->id)->delete(); RevokeUserTeamTokens::forUser($this->id); } diff --git a/database/migrations/2026_10_28_214953_add_index_for_interrupted_docker_cleanup_executions.php b/database/migrations/2026_10_28_214953_add_index_for_interrupted_docker_cleanup_executions.php new file mode 100644 index 0000000000..8b3081d3d6 --- /dev/null +++ b/database/migrations/2026_10_28_214953_add_index_for_interrupted_docker_cleanup_executions.php @@ -0,0 +1,45 @@ +index(['status', 'created_at'], 'idx_docker_cleanup_executions_unfinished_status_created'); + }); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_docker_cleanup_executions_unfinished_status_created'); + + return; + } + + if (Schema::hasIndex('docker_cleanup_executions', 'idx_docker_cleanup_executions_unfinished_status_created')) { + Schema::table('docker_cleanup_executions', function (Blueprint $table) { + $table->dropIndex('idx_docker_cleanup_executions_unfinished_status_created'); + }); + } + } +}; diff --git a/database/migrations/2026_10_28_224219_add_index_for_active_github_runner_executions.php b/database/migrations/2026_10_28_224219_add_index_for_active_github_runner_executions.php new file mode 100644 index 0000000000..425849e2d7 --- /dev/null +++ b/database/migrations/2026_10_28_224219_add_index_for_active_github_runner_executions.php @@ -0,0 +1,41 @@ +index(['status', 'server_id'], 'idx_github_runner_executions_status_server_id'); + }); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_github_runner_executions_status_server_id'); + + return; + } + + if (Schema::hasIndex('github_runner_executions', 'idx_github_runner_executions_status_server_id')) { + Schema::table('github_runner_executions', function (Blueprint $table) { + $table->dropIndex('idx_github_runner_executions_status_server_id'); + }); + } + } +}; diff --git a/database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php b/database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php new file mode 100644 index 0000000000..982cb2f60b --- /dev/null +++ b/database/migrations/2026_10_28_224423_add_index_for_dashboard_recent_deployments.php @@ -0,0 +1,41 @@ +index(['application_id', 'id'], 'idx_deployment_queues_app_id'); + }); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_deployment_queues_app_id'); + + return; + } + + if (Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) { + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropIndex('idx_deployment_queues_app_id'); + }); + } + } +}; diff --git a/database/migrations/2026_10_28_232000_add_index_for_active_servers_by_private_key.php b/database/migrations/2026_10_28_232000_add_index_for_active_servers_by_private_key.php new file mode 100644 index 0000000000..dd1187d9cc --- /dev/null +++ b/database/migrations/2026_10_28_232000_add_index_for_active_servers_by_private_key.php @@ -0,0 +1,45 @@ +index('private_key_id', 'idx_servers_private_key_id_active'); + }); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_servers_private_key_id_active'); + + return; + } + + if (Schema::hasIndex('servers', 'idx_servers_private_key_id_active')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropIndex('idx_servers_private_key_id_active'); + }); + } + } +}; diff --git a/openapi.json b/openapi.json index 53fba3b4c6..40ab42566c 100644 --- a/openapi.json +++ b/openapi.json @@ -21012,10 +21012,6 @@ "type": "string", "description": "The value of the environment variable." }, - "is_preview": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in preview deployments." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -21107,10 +21103,6 @@ "type": "string", "description": "The value of the environment variable." }, - "is_preview": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in preview deployments." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -21201,10 +21193,6 @@ "type": "string", "description": "The value of the environment variable." }, - "is_preview": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in preview deployments." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -26011,9 +25999,6 @@ "is_logdrain_custom_enabled": { "type": "boolean" }, - "is_logdrain_highlight_enabled": { - "type": "boolean" - }, "is_logdrain_newrelic_enabled": { "type": "boolean" }, @@ -26097,9 +26082,6 @@ "logdrain_custom_config_parser": { "type": "string" }, - "logdrain_highlight_project_id": { - "type": "string" - }, "logdrain_newrelic_base_uri": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index 3de7ed2693..6ae50f3662 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -13237,9 +13237,6 @@ paths: value: type: string description: 'The value of the environment variable.' - is_preview: - type: boolean - description: 'The flag to indicate if the environment variable is used in preview deployments.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -13300,9 +13297,6 @@ paths: value: type: string description: 'The value of the environment variable.' - is_preview: - type: boolean - description: 'The flag to indicate if the environment variable is used in preview deployments.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -13357,7 +13351,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object } type: object responses: '201': @@ -16650,8 +16644,6 @@ components: type: boolean is_logdrain_custom_enabled: type: boolean - is_logdrain_highlight_enabled: - type: boolean is_logdrain_newrelic_enabled: type: boolean is_metrics_enabled: @@ -16709,8 +16701,6 @@ components: type: string logdrain_custom_config_parser: type: string - logdrain_highlight_project_id: - type: string logdrain_newrelic_base_uri: type: string logdrain_newrelic_license_key: diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 4d594cb9e9..fea3c360c7 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -205,7 +205,45 @@ set_env_var() { fi } +# Quote a value so that Docker Compose and phpdotenv both read it back unchanged (same as install.sh). +quote_env_value() { + local value="$1" + case "$value" in + *\'*) + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//\$/\\\$} + printf '"%s"' "$value" + ;; + *) + printf "'%s'" "$value" + ;; + esac +} + +# Installers before 4.5.1 (and manual edits) wrote root user credentials with spaces without quotes. +# phpdotenv cannot parse such a line, so the recreated Coolify container returns HTTP 500 for every page. +# Only quote lines that phpdotenv rejects: unquoted values that contain whitespace but no comment. +quote_root_user_env_values() { + local key value + for key in ROOT_USERNAME ROOT_USER_EMAIL ROOT_USER_PASSWORD; do + value=$(grep -m1 "^${key}=" "$ENV_FILE" | cut -d '=' -f2-) || continue + # phpdotenv ignores whitespace around an unquoted value (also the \r of CRLF line endings) + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + case "$value" in + \"* | \'* | *[[:space:]]\#*) continue ;; + *[[:space:]]*) ;; + *) continue ;; + esac + # awk reads the value from the environment, so characters such as | & \ in it stay literal + KEY="$key" VALUE="$(quote_env_value "$value")" awk '!done && index($0, ENVIRON["KEY"] "=") == 1 { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"]; done = 1 } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + log "Quoted ${key} (its value has spaces)" + done +} + log "Checking environment variables..." +quote_root_user_env_values set_env_var "REGISTRY_URL" "$REGISTRY_URL" update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" diff --git a/public/svgs/hi-events.svg b/public/svgs/hi-events.svg new file mode 100644 index 0000000000..6af4872f91 --- /dev/null +++ b/public/svgs/hi-events.svg @@ -0,0 +1,20 @@ + + + Hi.Events Icon - Light + + + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 07aa70d5eb..6d4621eee2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3045,6 +3045,53 @@ input[type="search"]::-webkit-search-results-decoration { color: var(--color-fg-faint); } +.table-sort-header { + display: inline-flex; + min-width: 0; + max-width: 100%; + align-items: center; + justify-self: start; + gap: 0.25rem; + border-radius: 4px; + font: inherit; + color: inherit; + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: color 150ms; +} + +.table-sort-header:hover, +.table-sort-header.is-sorted { + color: #000; +} + +.dark .table-sort-header:hover, +.dark .table-sort-header.is-sorted { + color: var(--color-fg); +} + +.table-sort-header:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--color-accent, currentColor); +} + +.table-sort-header-icon { + width: 0.75rem; + height: 0.75rem; + flex-shrink: 0; + opacity: 0; + transition: opacity 150ms, transform 150ms; +} + +.table-sort-header:hover .table-sort-header-icon { + opacity: 0.5; +} + +.table-sort-header-icon.is-visible { + opacity: 1; +} + .data-table > .env-table-item { border-bottom: 1px solid var(--coollabs-fill); } @@ -3070,6 +3117,38 @@ input[type="search"]::-webkit-search-results-decoration { background: rgba(255, 255, 255, 0.02); } +/* A table cell value that filters the table by that value on click. */ +.table-filter-value { + position: relative; + z-index: 1; + max-width: 100%; + pointer-events: auto; + cursor: pointer; + text-align: left; + text-decoration-line: underline; + text-decoration-color: transparent; + text-underline-offset: 2px; + transition: color 0.15s, border-color 0.15s, text-decoration-color 0.15s; +} + +.table-filter-value:hover { + color: var(--color-black); + text-decoration-color: currentColor; +} + +.dark .table-filter-value:hover { + color: var(--color-white); +} + +.table-filter-value.table-filter-badge:hover { + text-decoration-color: transparent; + border-color: var(--color-neutral-400); +} + +.dark .table-filter-value.table-filter-badge:hover { + border-color: rgba(255, 255, 255, 0.3); +} + .env-table-grid { grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem; } @@ -3421,7 +3500,7 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 6rem; min-width: 50rem; } @@ -4195,6 +4274,18 @@ html[data-theme="custom"] .logs-viewer-timestamp { width: auto; } +/* Mobile: search on its own row, cancel/force-start + menus below it */ +@media (max-width: 639.98px) { + .logs-viewer-end:not(.runtime-logs-viewer-end) { + flex-wrap: wrap; + } + + .logs-viewer-end:not(.runtime-logs-viewer-end) > .logs-viewer-search { + order: -1; + flex: 1 1 100%; + } +} + .runtime-logs-viewer-end { align-items: stretch; flex-direction: column; @@ -4316,7 +4407,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { /* Desktop: [actions + status] ... [cancel + search] */ .logs-viewer-toolbar-controls { flex-direction: row; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; } diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php index 10480b9204..494bffb66e 100644 --- a/resources/views/auth/two-factor-challenge.blade.php +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -3,6 +3,8 @@
- +
+ @for ($i = 0; $i < 6; $i++) + + @endfor + +
+@else + +@endif diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index c0ab94172c..d551fd7d4b 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -8,36 +8,8 @@ @endif @php - $dashboardItemLimit = 8; - $dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit); - $dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit); + $dashboardItemLimit = \App\Livewire\Dashboard::ITEM_LIMIT; $hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled()); - $projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([ - $project->applications_count, - $project->services_count, - $project->postgresqls_count, - $project->redis_count, - $project->keydbs_count, - $project->dragonflies_count, - $project->clickhouses_count, - $project->mongodbs_count, - $project->mysqls_count, - $project->mariadbs_count, - $project->sqlites_count, - ])->sum()]); - $serverStatuses = $dashboardServers->mapWithKeys(function ($server) { - $proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); - $sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'; - - return [$server->id => 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'], - }]; - }); $iconButton = 'card-icon-button flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black'; // Same look as $iconButton, sized to the 20px section title it sits next to. $titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; @@ -188,11 +160,17 @@ @endforeach
-
+
-
Project
-
Contents
- + + +
@foreach ($dashboardProjects as $project) @@ -368,14 +346,20 @@ ? 'grid-cols-[minmax(0,1fr)_9.5rem] @xl:grid-cols-[minmax(0,1fr)_9rem_9.5rem] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9rem_9.5rem]' : 'grid-cols-[minmax(0,1fr)_9.5rem] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9.5rem]'; @endphp -
+
-
Server
- + + @if ($showServerUsage) @endif -
Status
+
@foreach ($dashboardServers as $server) @php diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php index 8cfeb299bb..143fa29e23 100644 --- a/resources/views/livewire/project/application/analytics.blade.php +++ b/resources/views/livewire/project/application/analytics.blade.php @@ -39,192 +39,197 @@ $approxBadge = fn (string $tooltip) => ' @else - @if ($live) - - @endif + {{-- Keyed so the lazy-load morph replaces the placeholder skeleton, which has the same + sections, instead of patching it in place: Alpine does not start the x-data + components (Locations toggle and globe, table pagers) on reused skeleton nodes. --}} +
+ @if ($live) + + @endif - - -
-
- - diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index d60651df80..727a647f7d 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -9,6 +9,15 @@ 'name' => strtolower($backup->targetName()), 'type' => strtolower($backup->targetType()), 'frequency' => strtolower($backup->frequency), + 'status' => match ($backup->latestExecution?->status) { + 'running' => 'in progress', + 'success' => 'success', + 'failed' => 'failed', + default => $backup->enabled ? 'waiting' : 'disabled', + }, + 's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->count() : -1, + 'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0), + 'executions' => $backup->executions_count, 'createdAt' => $backup->created_at?->timestamp ?? 0, ])->values()), filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge( @@ -30,11 +39,25 @@ const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter; return matchesSearch && matchesType; }); + const [column, direction] = this.sortBy.split('_'); + const sortValues = { + target: (backup) => backup.name, + type: (backup) => backup.type, + schedule: (backup) => backup.frequency, + status: (backup) => backup.status, + s3: (backup) => backup.s3, + lastrun: (backup) => backup.lastRunAt, + executions: (backup) => backup.executions, + }; + const sortValue = sortValues[column] || sortValues.target; + const order = direction === 'desc' ? -1 : 1; return filtered.sort((left, right) => { - if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name); if (this.sortBy === 'newest') return right.createdAt - left.createdAt; if (this.sortBy === 'oldest') return left.createdAt - right.createdAt; - return left.name.localeCompare(right.name); + const leftValue = sortValue(left); + const rightValue = sortValue(right); + return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) || + left.name.localeCompare(right.name); }); }, isVisible(id) { @@ -168,13 +191,14 @@ @if ($backups->isNotEmpty())
- Target - Type - Schedule - Status - S3 - Last run - Executions + + + + + + +
@foreach ($backups as $backup) @@ -205,7 +229,12 @@ title="{{ $backup->targetName() }}"> {{ $backup->targetName() }} - {{ $backup->targetType() }} + + {{ $backup->targetType() }} + {{ $backup->frequency }} diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index f7875deba2..c5fe02f54b 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -183,7 +183,7 @@ @if ($deployments->isNotEmpty())
+ wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter,setDeploymentSort"> @@ -202,7 +202,8 @@ Source Commit @unless ($embedded) - Started + @endunless Duration Server @@ -236,6 +237,15 @@ (bool) data_get($deployment, 'is_api') => 'API', default => 'Manual', }; + $sourceKey = match (true) { + $pullRequestId > 0 => 'pull-request', + (bool) data_get($deployment, 'is_webhook') => 'webhook', + (bool) data_get($deployment, 'rollback') => 'rollback', + (bool) data_get($deployment, 'is_api') => 'api', + default => 'manual', + }; + $serverId = data_get($deployment, 'server_id'); + $serverName = data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-'); $duration = match ($deploymentStatus) { 'queued' => 'Waiting', 'in_progress' => calculateDuration(data_get($deployment, 'created_at'), now()), @@ -264,9 +274,21 @@ x-bind:aria-busy="selecting === '{{ data_get($deployment, 'deployment_uuid') }}'" @endif> @unless ($embedded) - + + + @endunless - {{ $sourceLabel }} + @if ($embedded) + {{ $sourceLabel }} + @else + + + + @endif @if (data_get($deployment, 'commit')) @@ -285,7 +307,7 @@ @endif @if ($embedded) @endif @@ -295,9 +317,15 @@ @endunless {{ $duration }} - - {{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }} - + @if (! $embedded && $serverId) + + + + @else + {{ $serverName }} + @endif @if ($embedded) @endif
- @if ($this->application->is_github_based() && !$this->application->is_public_repository()) + @if ($this->application->supportsWatchPaths())
- @if ($this->application->is_github_based() && !$this->application->is_public_repository()) + @if ($this->application->supportsWatchPaths())
-
Project
-
Contents
-
Created
-
Description
+ + + +
@@ -233,11 +234,11 @@ label: 'Name Z–A' }, { - value: 'resources', + value: 'resources-desc', label: 'Most resources' }, { - value: 'environments', + value: 'environments-desc', label: 'Most environments' }, ], @@ -252,21 +253,23 @@ return !query || searchable.includes(query); }); - return projects.sort((first, second) => { - if (this.sortBy === 'name-desc') { - return second.name.localeCompare(first.name); - } - if (this.sortBy === 'resources') { - return second.resourceCount - first.resourceCount || - first.name.localeCompare(second.name); - } - if (this.sortBy === 'environments') { - return second.environmentCount - first.environmentCount || - first.name.localeCompare(second.name); - } + const [column, direction] = this.sortBy.split('-'); + const sortValues = { + name: (project) => project.name, + resources: (project) => Number(project.resourceCount), + environments: (project) => Number(project.environmentCount), + created: (project) => Date.parse(project.createdAt) || 0, + description: (project) => project.description || '', + }; + const sortValue = sortValues[column] || sortValues.name; + const order = direction === 'desc' ? -1 : 1; + const compare = (first, second) => typeof first === 'number' ? + first - second : + first.localeCompare(second); - return first.name.localeCompare(second.name); - }); + return projects.sort((first, second) => + order * compare(sortValue(first), sortValue(second)) || + first.name.localeCompare(second.name)); }, get totalPages() { return Math.max(1, Math.ceil(this.filteredProjects.length / this.pageSize)); diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index 8ec57b14ef..21f030f647 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -167,11 +167,11 @@ class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
-
Resource
-
Type
-
Status
-
Domain
-
Server
+ + + + +
Tags
@@ -206,11 +206,17 @@
-
+
+ +
-
- +
+ @@ -226,8 +232,13 @@ -
-
+
+ +