diff --git a/CHANGELOG.md b/CHANGELOG.md index 2980c7401..5660f2569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5389,7 +5389,6 @@ ### 🚀 Features - Add static ipv4 ipv6 support - Server disabled by overflow - Preview deployment logs -- Collect webhooks during maintenance - Logs and execute commands with several servers ### 🐛 Bug Fixes diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 61a3c4615..a1476e120 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -461,9 +461,10 @@ private function aggregateApplicationStatus($application, Collection $containerS } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount); + return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); } private function aggregateServiceContainerStatuses($services) @@ -518,8 +519,9 @@ private function aggregateServiceContainerStatuses($services) } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus) { diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 23b41e3f2..675f0f955 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -3,10 +3,12 @@ namespace App\Actions\Service; use App\Actions\Server\CleanupDocker; +use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; use App\Models\Server; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; class StopService { @@ -17,6 +19,17 @@ class StopService public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true) { try { + // Cancel any in-progress deployment activities so status doesn't stay stuck at "starting" + Activity::where('properties->type_uuid', $service->uuid) + ->where(function ($q) { + $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) + ->orWhere('properties->status', ProcessStatus::QUEUED->value); + }) + ->each(function ($activity) { + $activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value); + $activity->save(); + }); + $server = $service->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php index bd99a0f3c..3994dc0f8 100644 --- a/app/Events/ProxyStatusChangedUI.php +++ b/app/Events/ProxyStatusChangedUI.php @@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast public ?int $teamId = null; - public function __construct(?int $teamId = null) + public ?int $activityId = null; + + public function __construct(?int $teamId = null, ?int $activityId = null) { if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { $teamId = auth()->user()->currentTeam()->id; } $this->teamId = $teamId; + $this->activityId = $activityId; } public function broadcastOn(): array diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 078494f82..2f228119d 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; -use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,23 +14,6 @@ class Bitbucket extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Bitbicket::manual_bitbucket", $json); - - return; - } $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 3e0c5a0b6..e41825aba 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -7,7 +7,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -18,30 +17,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $gitea_delivery_found = collect($files)->filter(function ($file) use ($x_gitea_delivery) { - return Str::contains($file, $x_gitea_delivery); - })->first(); - if ($gitea_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitea::manual_{$x_gitea_delivery}", $json); - - return; - } $x_gitea_event = Str::lower($request->header('X-Gitea-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index a1fcaa7f5..2402b71ae 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -14,7 +14,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -25,30 +24,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::manual_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); @@ -310,30 +285,6 @@ public function normal(Request $request) $return_payloads = collect([]); $id = null; $x_github_delivery = $request->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::normal_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id'); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); @@ -624,23 +575,6 @@ public function install(Request $request) { try { $installation_id = $request->get('installation_id'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::install_{$installation_id}", $json); - - return; - } $source = $request->get('source'); $setup_action = $request->get('setup_action'); $github_app = GithubApp::where('uuid', $source)->firstOrFail(); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 3187663d4..56a9c0d1b 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -7,7 +7,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -16,24 +15,6 @@ class Gitlab extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitlab::manual_gitlab", $json); - - return; - } - $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index ae50aac42..d59adf0ca 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,7 +6,6 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; class Stripe extends Controller { @@ -20,23 +19,6 @@ public function events(Request $request) $signature, $webhookSecret ); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Stripe::events_stripe", $json); - - return response('Webhook received. Cool cool cool cool cool.', 200); - } StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6df9a8623..b6facba22 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1813,9 +1813,9 @@ private function health_check() $this->application->update(['status' => 'running']); $this->application_deployment_queue->addLogEntry('New container is healthy.'); break; - } - if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { + } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { $this->newVersionIsHealthy = false; + $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error'); $this->query_logs(); break; } @@ -3187,6 +3187,18 @@ private function stop_running_container(bool $force = false) $this->graceful_shutdown_container($this->container_name); } } catch (Exception $e) { + // If new version is healthy, this is just cleanup - don't fail the deployment + if ($this->newVersionIsHealthy || $force) { + $this->application_deployment_queue->addLogEntry( + "Warning: Could not remove old container: {$e->getMessage()}", + 'stderr', + hidden: true + ); + + return; // Don't re-throw - cleanup failures shouldn't fail successful deployments + } + + // Only re-throw if deployment hasn't succeeded yet throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e); } } diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 88484bcce..92ec4cbd4 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; @@ -38,6 +39,8 @@ public function handle(): void $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -48,16 +51,22 @@ public function handle(): void // Handle empty/null response from SSH command if (empty(trim($imageTag))) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } if (str_contains(strtolower(trim($imageTag)), ':latest')) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } // Parse current version to extract major.minor.patch $current = ltrim($currentVersion, 'v'); if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -77,6 +86,8 @@ public function handle(): void $this->server->update(['traefik_outdated_info' => null]); } + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -96,6 +107,9 @@ public function handle(): void // Fully up to date $this->server->update(['traefik_outdated_info' => null]); } + + // Dispatch UI update event so warning state refreshes in real-time + ProxyStatusChangedUI::dispatch($this->server->team_id); } /** diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 6917de6d5..84c4e879e 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -508,7 +508,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi } } } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -537,7 +537,7 @@ private function backup_standalone_postgresql(string $database): void } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -560,7 +560,7 @@ private function backup_standalone_mysql(string $database): void $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -583,7 +583,7 @@ private function backup_standalone_mariadb(string $database): void $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9d44e08f9..e6c64ada7 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -300,8 +300,9 @@ private function aggregateMultiContainerStatuses() } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -360,8 +361,9 @@ private function aggregateServiceContainerStatuses() // Use ContainerStatusAggregator service for state machine logic // NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0 + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index e3e809c8d..2815c73bc 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -2,9 +2,12 @@ namespace App\Jobs; -use App\Actions\Proxy\StartProxy; -use App\Actions\Proxy\StopProxy; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; +use App\Enums\ProxyTypes; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyDashboardCacheService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public $tries = 1; - public $timeout = 60; + public $timeout = 120; + + public ?int $activity_id = null; public function middleware(): array { - return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; + return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(120)->dontRelease()]; } public function __construct(public Server $server) {} @@ -31,15 +36,125 @@ public function __construct(public Server $server) {} public function handle() { try { - StopProxy::run($this->server, restarting: true); - + // Set status to restarting + $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; $this->server->save(); - StartProxy::run($this->server, force: true, restarting: true); + // Build combined stop + start commands for a single activity + $commands = $this->buildRestartCommands(); + + // Create activity and dispatch immediately - returns Activity right away + // The remote_process runs asynchronously, so UI gets activity ID instantly + $activity = remote_process( + $commands, + $this->server, + callEventOnFinish: 'ProxyStatusChanged', + callEventData: $this->server->id + ); + + // Store activity ID and notify UI immediately with it + $this->activity_id = $activity->id; + ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); } catch (\Throwable $e) { + // Set error status + $this->server->proxy->status = 'error'; + $this->server->save(); + + // Notify UI of error + ProxyStatusChangedUI::dispatch($this->server->team_id); + + // Clear dashboard cache on error + ProxyDashboardCacheService::clearCache($this->server); + return handleError($e); } } + + /** + * Build combined stop + start commands for proxy restart. + * This creates a single command sequence that shows all logs in one activity. + */ + private function buildRestartCommands(): array + { + $proxyType = $this->server->proxyType(); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $proxy_path = $this->server->proxyPath(); + $stopTimeout = 30; + + // Get proxy configuration + $configuration = GetProxyConfiguration::run($this->server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + SaveProxyConfiguration::run($this->server, $configuration); + $docker_compose_yml_base64 = base64_encode($configuration); + $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); + $this->server->save(); + + $commands = collect([]); + + // === STOP PHASE === + $commands = $commands->merge([ + "echo 'Stopping proxy...'", + "docker stop -t=$stopTimeout $containerName 2>/dev/null || true", + "docker rm -f $containerName 2>/dev/null || true", + '# Wait for container to be fully removed', + 'for i in {1..15}; do', + " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container removed successfully.'", + ' break', + ' fi', + ' echo "Waiting for container to be removed... ($i/15)"', + ' sleep 1', + ' # Force remove on each iteration in case it got stuck', + " docker rm -f $containerName 2>/dev/null || true", + 'done', + '# Final verification and force cleanup', + "if docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container still exists after wait, forcing removal...'", + " docker rm -f $containerName 2>/dev/null || true", + ' sleep 2', + 'fi', + "echo 'Proxy stopped successfully.'", + ]); + + // === START PHASE === + if ($this->server->isSwarmManager()) { + $commands = $commands->merge([ + "echo 'Starting proxy (Swarm mode)...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo 'Creating required Docker Compose file.'", + "echo 'Starting coolify-proxy.'", + 'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy', + "echo 'Successfully started coolify-proxy.'", + ]); + } else { + if (isDev() && $proxyType === ProxyTypes::CADDY->value) { + $proxy_path = '/data/coolify/proxy/caddy'; + } + $caddyfile = 'import /dynamic/*.caddy'; + $commands = $commands->merge([ + "echo 'Starting proxy...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo '$caddyfile' > $proxy_path/dynamic/Caddyfile", + "echo 'Creating required Docker Compose file.'", + "echo 'Pulling docker image.'", + 'docker compose pull', + ]); + // Ensure required networks exist BEFORE docker compose up + $commands = $commands->merge(ensureProxyNetworksExist($this->server)); + $commands = $commands->merge([ + "echo 'Starting coolify-proxy.'", + 'docker compose up -d --wait --remove-orphans', + "echo 'Successfully started coolify-proxy.'", + ]); + $commands = $commands->merge(connectProxyToNetworks($this->server)); + } + + return $commands->toArray(); + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index e55db5440..4cf8f0a6e 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -139,7 +139,7 @@ public function handle(): void if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $exec = "docker exec {$containerName} {$cmd}"; - $this->task_output = instant_remote_process([$exec], $this->server, true); + $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index e2929362f..20dc9987e 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -111,34 +111,48 @@ private function processScheduledTasks(Collection $servers): void private function processServerTasks(Server $server): void { + // Get server timezone (used for all scheduled tasks) + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + // Check if we should run sentinel-based checks $lastSentinelUpdate = $server->sentinel_updated_at; $waitTime = $server->waitBeforeDoingSshCheck(); - $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime)); + $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime)); if ($sentinelOutOfSync) { - // Dispatch jobs if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency)) { + // Dispatch ServerCheckJob if Sentinel is out of sync + if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { ServerCheckJob::dispatch($server); } + } - // Dispatch ServerStorageCheckJob if due - $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *'); + $isSentinelEnabled = $server->isSentinelEnabled(); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) + + if ($shouldRestartSentinel) { + dispatch(function () use ($server) { + $server->restartContainer('coolify-sentinel'); + }); + } + + // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) + // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data + if ($sentinelOutOfSync) { + $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'); if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency); + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); } } - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - // Dispatch ServerPatchCheckJob if due (weekly) $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); @@ -154,16 +168,6 @@ private function processServerTasks(Server $server): void CheckAndStartSentinelJob::dispatch($server); } } - - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); - - if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); - } } private function shouldRunNow(string $frequency, ?string $timezone = null): bool diff --git a/app/Listeners/MaintenanceModeDisabledNotification.php b/app/Listeners/MaintenanceModeDisabledNotification.php deleted file mode 100644 index 6c3ab83d8..000000000 --- a/app/Listeners/MaintenanceModeDisabledNotification.php +++ /dev/null @@ -1,48 +0,0 @@ -files(); - $files = collect($files); - $files = $files->sort(); - foreach ($files as $file) { - $content = Storage::disk('webhooks-during-maintenance')->get($file); - $data = json_decode($content, true); - $symfonyRequest = new SymfonyRequest( - $data['query'], - $data['request'], - $data['attributes'], - $data['cookies'], - $data['files'], - $data['server'], - $data['content'] - ); - - foreach ($data['headers'] as $key => $value) { - $symfonyRequest->headers->set($key, $value); - } - $request = Request::createFromBase($symfonyRequest); - $endpoint = str($file)->after('_')->beforeLast('_')->value(); - $class = "App\Http\Controllers\Webhook\\".ucfirst(str($endpoint)->before('::')->value()); - $method = str($endpoint)->after('::')->value(); - try { - $instance = new $class; - $instance->$method($request); - } catch (\Throwable $th) { - } finally { - Storage::disk('webhooks-during-maintenance')->delete($file); - } - } - } -} diff --git a/app/Listeners/MaintenanceModeEnabledNotification.php b/app/Listeners/MaintenanceModeEnabledNotification.php deleted file mode 100644 index 5aab248ea..000000000 --- a/app/Listeners/MaintenanceModeEnabledNotification.php +++ /dev/null @@ -1,21 +0,0 @@ -setupDynamicProxyConfiguration(); $server->proxy->force_stop = false; $server->save(); + + // Check Traefik version after proxy is running + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $traefikVersions = get_traefik_versions(); + if ($traefikVersions !== null) { + CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); + } else { + Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + ]); + } + } } if ($status === 'created') { instant_remote_process([ diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index cdac47d3d..87f7cff8a 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -18,6 +18,8 @@ class Show extends Component public $isKeepAliveOn = true; + public bool $is_debug_enabled = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -56,9 +58,23 @@ public function mount() $this->application_deployment_queue = $application_deployment_queue; $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->deployment_uuid = $deploymentUuid; + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->isKeepAliveOn(); } + public function toggleDebug() + { + try { + $this->authorize('update', $this->application); + $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; + $this->application->settings->save(); + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->application_deployment_queue->refresh(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshQueue() { $this->application_deployment_queue->refresh(); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index ef474fb02..c84de9d8d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -521,7 +521,7 @@ public function instantSave() } } - public function loadComposeFile($isInit = false, $showToast = true) + public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { try { $this->authorize('update', $this->application); @@ -530,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true) return; } - ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit); + ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit, $restoreBaseDirectory, $restoreDockerComposeLocation); if (is_null($this->parsedServices)) { $showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); @@ -606,13 +606,6 @@ public function generateDomain(string $serviceName) } } - public function updatedBaseDirectory() - { - if ($this->buildPack === 'dockercompose') { - $this->loadComposeFile(); - } - } - public function updatedIsStatic($value) { if ($value) { @@ -786,11 +779,13 @@ public function submit($showToaster = true) try { $this->authorize('update', $this->application); + $this->resetErrorBag(); $this->validate(); $oldPortsExposes = $this->application->ports_exposes; $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; $oldDockerComposeLocation = $this->initialDockerComposeLocation; + $oldBaseDirectory = $this->application->base_directory; // Process FQDN with intermediate variable to avoid Collection/string confusion $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); @@ -821,6 +816,42 @@ public function submit($showToaster = true) return; // Stop if there are conflicts and user hasn't confirmed } + // Normalize paths BEFORE validation + if ($this->baseDirectory && $this->baseDirectory !== '/') { + $this->baseDirectory = rtrim($this->baseDirectory, '/'); + $this->application->base_directory = $this->baseDirectory; + } + if ($this->publishDirectory && $this->publishDirectory !== '/') { + $this->publishDirectory = rtrim($this->publishDirectory, '/'); + $this->application->publish_directory = $this->publishDirectory; + } + + // Validate docker compose file path BEFORE saving to database + // This prevents invalid paths from being persisted when validation fails + if ($this->buildPack === 'dockercompose' && + ($oldDockerComposeLocation !== $this->dockerComposeLocation || + $oldBaseDirectory !== $this->baseDirectory)) { + // Pass original values to loadComposeFile so it can restore them on failure + // The finally block in Application::loadComposeFile will save these original + // values if validation fails, preventing invalid paths from being persisted + $compose_return = $this->loadComposeFile( + isInit: false, + showToast: false, + restoreBaseDirectory: $oldBaseDirectory, + restoreDockerComposeLocation: $oldDockerComposeLocation + ); + if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { + // Validation failed - restore original values to component properties + $this->baseDirectory = $oldBaseDirectory; + $this->dockerComposeLocation = $oldDockerComposeLocation; + // The model was saved by loadComposeFile's finally block with original values + // Refresh to sync component with database state + $this->application->refresh(); + + return; + } + } + $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); @@ -828,13 +859,6 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) { - $compose_return = $this->loadComposeFile(showToast: false); - if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { - return; - } - } - if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) { $this->resetDefaultLabels(); } @@ -855,14 +879,6 @@ public function submit($showToaster = true) $this->application->ports_exposes = $port; } } - if ($this->baseDirectory && $this->baseDirectory !== '/') { - $this->baseDirectory = rtrim($this->baseDirectory, '/'); - $this->application->base_directory = $this->baseDirectory; - } - if ($this->publishDirectory && $this->publishDirectory !== '/') { - $this->publishDirectory = rtrim($this->publishDirectory, '/'); - $this->application->publish_directory = $this->publishDirectory; - } if ($this->buildPack === 'dockercompose') { $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); if ($this->application->isDirty('docker_compose_domains')) { diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 27ecacb99..5dd508c29 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,16 +75,6 @@ public function mount() $this->github_apps = GithubApp::private(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 89814ee7f..2fffff6b9 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -107,26 +107,6 @@ public function mount() $this->query = request()->query(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - - public function updatedDockerComposeLocation() - { - if ($this->docker_compose_location) { - $this->docker_compose_location = rtrim($this->docker_compose_location, '/'); - if (! str($this->docker_compose_location)->startsWith('/')) { - $this->docker_compose_location = '/'.$this->docker_compose_location; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 3ed2befba..f86d88208 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -39,10 +39,12 @@ class GetLogs extends Component public ?bool $streamLogs = false; - public ?bool $showTimeStamps = false; + public ?bool $showTimeStamps = true; public ?int $numberOfLines = 100; + public bool $expandByDefault = false; + public function mount() { if (! is_null($this->resource)) { @@ -92,6 +94,27 @@ public function instantSave() } } + public function toggleTimestamps() + { + $previousValue = $this->showTimeStamps; + $this->showTimeStamps = ! $this->showTimeStamps; + + try { + $this->instantSave(); + $this->getLogs(true); + } catch (\Throwable $e) { + // Revert the flag to its previous value on failure + $this->showTimeStamps = $previousValue; + + return handleError($e, $this); + } + } + + public function toggleStreamLogs() + { + $this->streamLogs = ! $this->streamLogs; + } + public function getLogs($refresh = false) { if (! $this->server->isFunctional()) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index d7210c15d..2d6b76c25 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -41,7 +41,7 @@ class Add extends Component 'command' => 'required|string', 'frequency' => 'required|string', 'container' => 'nullable|string', - 'timeout' => 'required|integer|min:60|max:3600', + 'timeout' => 'required|integer|min:60|max:36000', ]; protected $validationAttributes = [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 088de0a76..f7947951b 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -40,7 +40,7 @@ class Show extends Component #[Validate(['string', 'nullable'])] public ?string $container = null; - #[Validate(['integer', 'required', 'min:60', 'max:3600'])] + #[Validate(['integer', 'required', 'min:60', 'max:36000'])] public $timeout = 300; #[Locked] diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 6725e5d0a..cd9cfcba6 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -6,11 +6,10 @@ use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StopProxy; use App\Enums\ProxyTypes; -use App\Jobs\CheckTraefikVersionForServerJob; +use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Log; use Livewire\Component; class Navbar extends Component @@ -29,6 +28,10 @@ class Navbar extends Component public ?string $proxyStatus = 'unknown'; + public ?string $lastNotifiedStatus = null; + + public bool $restartInitiated = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -63,27 +66,19 @@ public function restart() { try { $this->authorize('manageProxy', $this->server); - StopProxy::run($this->server, restarting: true); - $this->server->proxy->force_stop = false; - $this->server->save(); - - $activity = StartProxy::run($this->server, force: true, restarting: true); - $this->dispatch('activityMonitor', $activity->id); - - // Check Traefik version after restart to provide immediate feedback - if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) { - $traefikVersions = get_traefik_versions(); - if ($traefikVersions !== null) { - CheckTraefikVersionForServerJob::dispatch($this->server, $traefikVersions); - } else { - Log::warning('Traefik version check skipped: versions.json data unavailable', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); - } + // Prevent duplicate restart calls + if ($this->restartInitiated) { + return; } + $this->restartInitiated = true; + + // Always use background job for all servers + RestartProxyJob::dispatch($this->server); + } catch (\Throwable $e) { + $this->restartInitiated = false; + return handleError($e, $this); } } @@ -137,12 +132,27 @@ public function checkProxyStatus() } } - public function showNotification() + public function showNotification($event = null) { $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + // If event contains activityId, open activity monitor + if ($event && isset($event['activityId'])) { + $this->dispatch('activityMonitor', $event['activityId']); + } + + // Reset restart flag when proxy reaches a stable state + if (in_array($this->proxyStatus, ['running', 'exited', 'error'])) { + $this->restartInitiated = false; + } + + // Skip notification if we already notified about this status (prevents duplicates) + if ($this->lastNotifiedStatus === $this->proxyStatus) { + return; + } + switch ($this->proxyStatus) { case 'running': $this->loadProxyConfiguration(); @@ -150,6 +160,7 @@ public function showNotification() // Don't show during normal start/restart flows (starting, restarting, stopping) if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) { $this->dispatch('success', 'Proxy is running.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'exited': @@ -157,19 +168,30 @@ public function showNotification() // Don't show during normal stop/restart flows (stopping, restarting) if (in_array($previousStatus, ['running'])) { $this->dispatch('info', 'Proxy has exited.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'stopping': - $this->dispatch('info', 'Proxy is stopping.'); + // $this->dispatch('info', 'Proxy is stopping.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'starting': - $this->dispatch('info', 'Proxy is starting.'); + // $this->dispatch('info', 'Proxy is starting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'restarting': + // $this->dispatch('info', 'Proxy is restarting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'error': + $this->dispatch('error', 'Proxy restart failed. Check logs.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'unknown': - $this->dispatch('info', 'Proxy status is unknown.'); + // Don't notify for unknown status - too noisy break; default: - $this->dispatch('info', 'Proxy status updated.'); + // Don't notify for other statuses break; } diff --git a/app/Models/Application.php b/app/Models/Application.php index 6e920f8e6..7bddce32b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1511,9 +1511,11 @@ public function parse(int $pull_request_id = 0, ?int $preview_id = null) } } - public function loadComposeFile($isInit = false) + public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { - $initialDockerComposeLocation = $this->docker_compose_location; + // Use provided restore values or capture current values as fallback + $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location; + $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory; if ($isInit && $this->docker_compose_raw) { return; } @@ -1580,6 +1582,7 @@ public function loadComposeFile($isInit = false) throw new \RuntimeException($e->getMessage()); } finally { $this->docker_compose_location = $initialDockerComposeLocation; + $this->base_directory = $initialBaseDirectory; $this->save(); $commands = collect([ "rm -rf /tmp/{$uuid}", diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 843f01e59..895dc1c43 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -65,6 +65,8 @@ protected static function booted() 'value' => $environment_variable->value, 'is_multiline' => $environment_variable->is_multiline ?? false, 'is_literal' => $environment_variable->is_literal ?? false, + 'is_runtime' => $environment_variable->is_runtime ?? false, + 'is_buildtime' => $environment_variable->is_buildtime ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index 09ef4257d..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -43,9 +43,19 @@ public function toMail($notifiable = null): MailMessage $mail = new MailMessage; $count = $this->servers->count(); + // Transform servers to include URLs + $serversWithUrls = $this->servers->map(function ($server) { + return [ + 'name' => $server->name, + 'uuid' => $server->uuid, + 'url' => base_url().'/server/'.$server->uuid.'/proxy', + 'outdatedInfo' => $server->outdatedInfo ?? [], + ]; + }); + $mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)"); $mail->view('emails.traefik-version-outdated', [ - 'servers' => $this->servers, + 'servers' => $serversWithUrls, 'count' => $count, ]); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 2d9910add..9163d595c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,10 +2,6 @@ namespace App\Providers; -use App\Listeners\MaintenanceModeDisabledNotification; -use App\Listeners\MaintenanceModeEnabledNotification; -use Illuminate\Foundation\Events\MaintenanceModeDisabled; -use Illuminate\Foundation\Events\MaintenanceModeEnabled; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; @@ -19,12 +15,6 @@ class EventServiceProvider extends ServiceProvider { protected $listen = [ - MaintenanceModeEnabled::class => [ - MaintenanceModeEnabledNotification::class, - ], - MaintenanceModeDisabled::class => [ - MaintenanceModeDisabledNotification::class, - ], SocialiteWasCalled::class => [ AzureExtendSocialite::class.'@handle', AuthentikExtendSocialite::class.'@handle', diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 4a17ecdd6..2be36d905 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -16,14 +16,23 @@ * UI components transform this to human-readable format (e.g., "Running (Healthy)"). * * State Priority (highest to lowest): - * 1. Restarting → degraded:unhealthy - * 2. Crash Loop (exited with restarts) → degraded:unhealthy - * 3. Mixed (running + exited) → degraded:unhealthy - * 4. Running → running:healthy/unhealthy/unknown - * 5. Dead/Removing → degraded:unhealthy - * 6. Paused → paused:unknown - * 7. Starting/Created → starting:unknown - * 8. Exited → exited + * 1. Degraded (from sub-resources) → degraded:unhealthy + * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true) + * 3. Crash Loop (exited with restarts) → degraded:unhealthy + * 4. Mixed (running + exited) → degraded:unhealthy + * 5. Mixed (running + starting) → starting:unknown + * 6. Running → running:healthy/unhealthy/unknown + * 7. Dead/Removing → degraded:unhealthy + * 8. Paused → paused:unknown + * 9. Starting/Created → starting:unknown + * 10. Exited → exited + * + * The $preserveRestarting parameter controls whether "restarting" containers should be + * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). + * - Use preserveRestarting=true for individual sub-resources (ServiceApplication/ServiceDatabase) + * so they show "Restarting" in the UI. + * - Use preserveRestarting=false for overall Service status aggregation where any restarting + * container should mark the entire service as "Degraded". */ class ContainerStatusAggregator { @@ -32,9 +41,10 @@ class ContainerStatusAggregator * * @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy") * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string + public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -64,10 +74,16 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting = false; $hasPaused = false; $hasDead = false; + $hasDegraded = false; // Parse each status string and set flags foreach ($containerStatuses as $status) { - if (str($status)->contains('restarting')) { + if (str($status)->contains('degraded')) { + $hasDegraded = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('restarting')) { $hasRestarting = true; } elseif (str($status)->contains('running')) { $hasRunning = true; @@ -98,7 +114,9 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + $hasDegraded, + $maxRestartCount, + $preserveRestarting ); } @@ -107,9 +125,10 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest * * @param Collection $containers Collection of Docker container objects with State property * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string + public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -175,7 +194,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + false, // $hasDegraded - not applicable for container objects, only for status strings + $maxRestartCount, + $preserveRestarting ); } @@ -190,7 +211,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC * @param bool $hasStarting Has at least one starting/created container * @param bool $hasPaused Has at least one paused container * @param bool $hasDead Has at least one dead/removing container + * @param bool $hasDegraded Has at least one degraded container * @param int $maxRestartCount Maximum restart count (for crash loop detection) + * @param bool $preserveRestarting If true, return "restarting:unknown" instead of "degraded:unhealthy" for restarting containers * @return string Status in colon format (e.g., "running:healthy") */ private function resolveStatus( @@ -202,24 +225,40 @@ private function resolveStatus( bool $hasStarting, bool $hasPaused, bool $hasDead, - int $maxRestartCount + bool $hasDegraded, + int $maxRestartCount, + bool $preserveRestarting = false ): string { - // Priority 1: Restarting containers (degraded state) - if ($hasRestarting) { + // Priority 1: Degraded containers from sub-resources (highest priority) + // If any service/application within a service stack is degraded, the entire stack is degraded + if ($hasDegraded) { return 'degraded:unhealthy'; } - // Priority 2: Crash loop detection (exited with restart count > 0) + // Priority 2: Restarting containers + // When preserveRestarting is true (for individual sub-resources), keep as "restarting" + // When false (for overall service status), mark as "degraded" + if ($hasRestarting) { + return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; + } + + // Priority 3: Crash loop detection (exited with restart count > 0) if ($hasExited && $maxRestartCount > 0) { return 'degraded:unhealthy'; } - // Priority 3: Mixed state (some running, some exited = degraded) + // Priority 4: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 4: Running containers (check health status) + // Priority 5: Mixed state (some running, some starting = still starting) + // If any component is still starting, the entire service stack is not fully ready + if ($hasRunning && $hasStarting) { + return 'starting:unknown'; + } + + // Priority 6: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -230,22 +269,22 @@ private function resolveStatus( } } - // Priority 5: Dead or removing containers + // Priority 7: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 6: Paused containers + // Priority 8: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 7: Starting/created containers + // Priority 9: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 8: All containers exited (no restart count = truly stopped) + // Priority 10: All containers exited (no restart count = truly stopped) return 'exited'; } } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index db7767c1e..7a36c4b63 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -68,10 +68,16 @@ function queue_application_deployment(Application $application, string $deployme ]); if ($no_questions_asked) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 178876b89..9196f9fb8 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -48,6 +48,8 @@ 'influxdb', 'clickhouse/clickhouse-server', 'timescaledb/timescaledb', + 'timescaledb', // Matches timescale/timescaledb + 'timescaledb-ha', // Matches timescale/timescaledb-ha 'pgvector/pgvector', ]; const SPECIFIC_SERVICES = [ diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f6d69ef60..759d345b0 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -770,10 +770,26 @@ function isDatabaseImage(?string $image = null, ?array $serviceConfig = null) } $imageName = $image->before(':'); - // First check if it's a known database image + // Extract base image name (ignore registry prefix) + // Examples: + // docker.io/library/postgres -> postgres + // ghcr.io/postgrest/postgrest -> postgrest + // postgres -> postgres + // postgrest/postgrest -> postgrest + $baseImageName = $imageName; + if (str($imageName)->contains('/')) { + $baseImageName = str($imageName)->afterLast('/'); + } + + // Check if base image name exactly matches a known database image $isKnownDatabase = false; foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) { - if (str($imageName)->contains($database_docker_image)) { + // Extract base name from database pattern for comparison + $databaseBaseName = str($database_docker_image)->contains('/') + ? str($database_docker_image)->afterLast('/') + : $database_docker_image; + + if ($baseImageName == $databaseBaseName) { $isKnownDatabase = true; break; } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 3218bf878..edddf968d 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -118,7 +118,7 @@ function () use ($server, $command_string) { ); } -function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string +function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false, ?int $timeout = null): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; @@ -126,11 +126,12 @@ function instant_remote_process(Collection|array $command, Server $server, bool $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); + $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout'); return \App\Helpers\SshRetryHandler::retry( - function () use ($server, $command_string) { + function () use ($server, $command_string, $effectiveTimeout) { $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + $process = Process::timeout($effectiveTimeout)->run($sshCommand); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/config/filesystems.php b/config/filesystems.php index c2df26c84..ba0921a79 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,13 +35,6 @@ 'throw' => false, ], - 'webhooks-during-maintenance' => [ - 'driver' => 'local', - 'root' => storage_path('app/webhooks-during-maintenance'), - 'visibility' => 'private', - 'throw' => false, - ], - 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cd4a307aa..3116a4185 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 09ce3ead3..6306ab381 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/install.sh b/other/nightly/install.sh index ac4e3caff..b037fe382 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/public/svgs/fizzy.png b/public/svgs/fizzy.png new file mode 100644 index 000000000..44efbd781 Binary files /dev/null and b/public/svgs/fizzy.png differ diff --git a/public/svgs/rustfs.png b/public/svgs/rustfs.png new file mode 100644 index 000000000..927b8c5c4 Binary files /dev/null and b/public/svgs/rustfs.png differ diff --git a/public/svgs/rustfs.svg b/public/svgs/rustfs.svg new file mode 100644 index 000000000..18e9b8418 --- /dev/null +++ b/public/svgs/rustfs.svg @@ -0,0 +1,15 @@ + diff --git a/resources/css/app.css b/resources/css/app.css index 931e3fe19..30371d307 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -185,4 +185,15 @@ .input[type="password"] { .lds-heart { animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.log-highlight { + background-color: rgba(234, 179, 8, 0.4); + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.dark .log-highlight { + background-color: rgba(234, 179, 8, 0.3); } \ No newline at end of file diff --git a/resources/views/emails/traefik-version-outdated.blade.php b/resources/views/emails/traefik-version-outdated.blade.php index 28effabf3..91c627a73 100644 --- a/resources/views/emails/traefik-version-outdated.blade.php +++ b/resources/views/emails/traefik-version-outdated.blade.php @@ -5,10 +5,12 @@ @foreach ($servers as $server) @php - $info = $server->outdatedInfo ?? []; - $current = $info['current'] ?? 'unknown'; - $latest = $info['latest'] ?? 'unknown'; - $isPatch = ($info['type'] ?? 'patch_update') === 'patch_update'; + $serverName = data_get($server, 'name', 'Unknown Server'); + $serverUrl = data_get($server, 'url', '#'); + $info = data_get($server, 'outdatedInfo', []); + $current = data_get($info, 'current', 'unknown'); + $latest = data_get($info, 'latest', 'unknown'); + $isPatch = (data_get($info, 'type', 'patch_update') === 'patch_update'); $hasNewerBranch = isset($info['newer_branch_target']); $hasUpgrades = $hasUpgrades ?? false; if (!$isPatch || $hasNewerBranch) { @@ -19,8 +21,9 @@ $latest = str_starts_with($latest, 'v') ? $latest : "v{$latest}"; // For minor upgrades, use the upgrade_target (e.g., "v3.6") - if (!$isPatch && isset($info['upgrade_target'])) { - $upgradeTarget = str_starts_with($info['upgrade_target'], 'v') ? $info['upgrade_target'] : "v{$info['upgrade_target']}"; + if (!$isPatch && data_get($info, 'upgrade_target')) { + $upgradeTarget = data_get($info, 'upgrade_target'); + $upgradeTarget = str_starts_with($upgradeTarget, 'v') ? $upgradeTarget : "v{$upgradeTarget}"; } else { // For patch updates, show the full version $upgradeTarget = $latest; @@ -28,22 +31,23 @@ // Get newer branch info if available if ($hasNewerBranch) { - $newerBranchTarget = $info['newer_branch_target']; - $newerBranchLatest = str_starts_with($info['newer_branch_latest'], 'v') ? $info['newer_branch_latest'] : "v{$info['newer_branch_latest']}"; + $newerBranchTarget = data_get($info, 'newer_branch_target', 'unknown'); + $newerBranchLatest = data_get($info, 'newer_branch_latest', 'unknown'); + $newerBranchLatest = str_starts_with($newerBranchLatest, 'v') ? $newerBranchLatest : "v{$newerBranchLatest}"; } @endphp @if ($isPatch && $hasNewerBranch) -- **{{ $server->name }}**: {{ $current }} → {{ $upgradeTarget }} (patch update available) | Also available: {{ $newerBranchTarget }} (latest patch: {{ $newerBranchLatest }}) - new minor version +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} → {{ $upgradeTarget }} (patch update available) | Also available: {{ $newerBranchTarget }} (latest patch: {{ $newerBranchLatest }}) - new minor version @elseif ($isPatch) -- **{{ $server->name }}**: {{ $current }} → {{ $upgradeTarget }} (patch update available) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} → {{ $upgradeTarget }} (patch update available) @else -- **{{ $server->name }}**: {{ $current }} (latest patch: {{ $latest }}) → {{ $upgradeTarget }} (new minor version available) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} (latest patch: {{ $latest }}) → {{ $upgradeTarget }} (new minor version available) @endif @endforeach ## Recommendation -It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration through your [Coolify Dashboard]({{ config('app.url') }}). +It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration by clicking on any server name above. @if ($hasUpgrades ?? false) **Important for minor version upgrades:** Before upgrading to a new minor version, please read the [Traefik changelog](https://github.com/traefik/traefik/releases) to understand breaking changes and new features. @@ -58,5 +62,5 @@ --- -You can manage your server proxy settings in your Coolify Dashboard. +Click on any server name above to manage its proxy settings. diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 7bb366cd4..2b4ca6054 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -83,7 +83,7 @@ if (!html) return ''; const URL_RE = /^(https?:|mailto:)/i; const config = { - ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong', + ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'mark', 'p', 'pre', 's', 'span', 'strong', 'u' ], ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'], diff --git a/resources/views/livewire/project/application/deployment-navbar.blade.php b/resources/views/livewire/project/application/deployment-navbar.blade.php index 60c660bf7..8d0fc18fb 100644 --- a/resources/views/livewire/project/application/deployment-navbar.blade.php +++ b/resources/views/livewire/project/application/deployment-navbar.blade.php @@ -1,18 +1,12 @@