coolify/app/Livewire/Project/Application/Previews.php
Andras Bacsai d75881fa96 feat(domains): keep domain drafts and move preview settings
Preserve in-progress domain edits and redirects across Livewire
refreshes, copy www port overrides for service redirect pairs, and
relocate preview deployment toggles from Advanced to Previews.
Unsaved bars can stay dirty via Alpine while a modal is closed, and
service domain tables stack at narrow widths.
2026-09-07 18:29:58 +02:00

348 lines
14 KiB
PHP

<?php
namespace App\Livewire\Project\Application;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ServiceStatusChanged;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class Previews extends Component
{
use AuthorizesRequests;
protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains'];
public Application $application;
public bool $isPreviewDeploymentsEnabled = false;
public bool $isPrDeploymentsPublicEnabled = false;
public string $deployment_uuid;
public array $parameters;
public Collection $pull_requests;
public int $rate_limit_remaining;
public array $previewDockerTags = [];
public ?int $manualPullRequestId = null;
public ?string $manualDockerTag = null;
protected $rules = [
'previewDockerTags.*' => 'string|nullable',
'manualPullRequestId' => 'integer|min:1|nullable',
'manualDockerTag' => 'string|nullable',
];
public function mount()
{
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->pull_requests = collect();
$this->parameters = get_route_parameters();
$this->syncDockerTags();
}
public function savePreviewSettings(): void
{
$this->authorize('update', $this->application);
$this->validate([
'isPreviewDeploymentsEnabled' => 'boolean',
'isPrDeploymentsPublicEnabled' => 'boolean',
]);
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
$this->application->settings->save();
$this->dispatch('success', 'Settings saved.');
$this->dispatch('configurationChanged');
}
private function syncDockerTags(): void
{
$this->previewDockerTags = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
}
}
public function refreshPreviewDomains(): void
{
$this->application->refresh();
$this->syncDockerTags();
}
public function load_prs()
{
try {
$this->authorize('update', $this->application);
['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls");
$this->rate_limit_remaining = $rate_limit_remaining;
$this->pull_requests = $data->sortBy('number')->values();
} catch (\Throwable $e) {
$this->rate_limit_remaining = 0;
return handleError($e, $this);
}
}
public function save_preview($preview_id)
{
try {
$this->authorize('update', $this->application);
$preview = $this->application->previews->find($preview_id);
if (! $preview) {
throw new \Exception('Preview not found');
}
$previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
return $item->id == $preview_id;
});
if ($previewKey === false) {
throw new \Exception('Preview not found');
}
$this->validateOnly("previewDockerTags.{$previewKey}");
$preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null;
$preview->save();
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
try {
$this->authorize('update', $this->application);
if ($this->application->build_pack === 'dockercompose') {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && ! is_null($pull_request_html_url)) {
$found = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'docker_compose_domains' => $this->application->docker_compose_domains,
]);
}
$found->generate_preview_fqdn_compose();
$this->application->refresh();
$this->syncDockerTags();
} else {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
$found = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url ?? '',
'docker_registry_image_tag' => $docker_registry_image_tag,
]);
}
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
$found->docker_registry_image_tag = $docker_registry_image_tag;
$found->save();
}
$found->generate_preview_fqdn(generateWithoutApplicationDomain: true);
$this->application->refresh();
$this->syncDockerTags();
$this->dispatch('update_links');
$this->dispatch('success', 'Preview added.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
{
try {
$this->authorize('deploy', $this->application);
$dockerRegistryImageTag = null;
if ($this->application->build_pack === 'dockerimage') {
$dockerRegistryImageTag = $this->application->previews()
->where('pull_request_id', $pull_request_id)
->value('docker_registry_image_tag');
}
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
try {
$this->authorize('deploy', $this->application);
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
{
try {
$this->authorize('deploy', $this->application);
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
$found = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url ?? '',
'docker_registry_image_tag' => $docker_registry_image_tag,
]);
}
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
$found->docker_registry_image_tag = $docker_registry_image_tag;
$found->save();
}
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deployment_uuid,
force_rebuild: $force_rebuild,
pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null,
docker_registry_image_tag: $docker_registry_image_tag,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return redirect()->route('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deployment_uuid,
'environment_uuid' => $this->parameters['environment_uuid'],
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
protected function setDeploymentUuid()
{
$this->deployment_uuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}
public function addDockerImagePreview()
{
$this->authorize('deploy', $this->application);
$this->validateOnly('manualPullRequestId');
$this->validateOnly('manualDockerTag');
if ($this->application->build_pack !== 'dockerimage') {
$this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.');
return;
}
if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) {
$this->dispatch('error', 'Both pull request id and docker tag are required.');
return;
}
$dockerTag = str($this->manualDockerTag)->trim()->value();
$this->add_and_deploy($this->manualPullRequestId, null, $dockerTag);
$this->manualPullRequestId = null;
$this->manualDockerTag = null;
}
private function stopContainers(array $containers, $server)
{
$containersToStop = collect($containers)->pluck('Names')->toArray();
$timeout = $this->application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
}
public function stop(int $pull_request_id)
{
try {
$this->authorize('deploy', $this->application);
$server = $this->application->destination->server;
if ($this->application->destination->server->isSwarm()) {
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server);
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray();
$this->stopContainers($containers, $server);
}
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
$this->application->refresh();
$this->dispatch('containerStatusUpdated');
$this->dispatch('success', 'Preview Deployment stopped.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function delete(int $pull_request_id)
{
try {
$this->authorize('delete', $this->application);
$preview = ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->first();
if (! $preview) {
$this->dispatch('error', 'Preview not found.');
return;
}
// Soft delete immediately for instant UI feedback
$preview->delete();
// Dispatch the job for async cleanup (container stopping + force delete)
DeleteResourceJob::dispatch($preview);
// Refresh the application and its previews relationship to reflect the soft delete
$this->application->load('previews');
$this->dispatch('update_links');
$this->dispatch('success', 'Preview deletion started. It may take a few moments to complete.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}