Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

@@ -0,0 +1,170 @@
<?php
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
#[Locked]
public Application $application;
public ?string $selectedTargetKey = null;
public ?string $targetKey = null;
public bool $targetLocked = false;
public string $frequency = 'daily';
public Collection $targets;
protected function rules(): array
{
return [
'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'],
'frequency' => ['required', 'string'],
];
}
public function mount(): void
{
$this->authorize('view', $this->application);
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey;
// When opened from a volume row the target is fixed — skip listing every volume/directory.
if ($this->targetLocked && is_string($this->selectedTargetKey)) {
$target = $this->selectedTarget();
if ($target instanceof LocalPersistentVolume) {
$this->targets = collect([[
'key' => 'volume:'.$target->id,
'type' => 'Volume',
'name' => $target->name,
]]);
} elseif ($target instanceof LocalFileVolume) {
$this->targets = collect([[
'key' => 'directory:'.$target->id,
'type' => 'Directory',
'name' => $target->fs_path,
]]);
} else {
$this->targets = collect();
}
$this->loadSelectedBackup();
return;
}
$volumes = $this->application->persistentStorages()
->orderBy('name')
->get()
->map(fn (LocalPersistentVolume $volume): array => [
'key' => 'volume:'.$volume->id,
'type' => 'Volume',
'name' => $volume->name,
]);
$directories = $this->application->fileStorages()
->where('is_directory', true)
->where('is_host_file', false)
->orderBy('fs_path')
->get()
->map(fn (LocalFileVolume $directory): array => [
'key' => 'directory:'.$directory->id,
'type' => 'Directory',
'name' => $directory->fs_path,
]);
$this->targets = $volumes->concat($directories)->values();
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup();
}
public function updatedTargetKey(): void
{
$this->loadSelectedBackup();
}
public function submit(): void
{
$this->authorize('update', $this->application);
$this->validate();
$target = $this->selectedTarget();
if (! $target) {
$this->addError('targetKey', 'Select a volume or directory owned by this application.');
return;
}
if (! validate_cron_expression($this->frequency)) {
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
return;
}
try {
$backup = $target->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => true,
],
);
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
redirectRoute($this, 'project.application.backup.show', [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
'application_uuid' => $this->application->uuid,
'backup_uuid' => $backup->uuid,
]);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.application.backup.create');
}
private function loadSelectedBackup(): void
{
$target = $this->selectedTarget();
if (! $target) {
return;
}
$backup = $target->scheduledBackups()->first();
if ($backup) {
$this->frequency = $backup->frequency;
}
}
private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null
{
[$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null);
if (! ctype_digit((string) $id)) {
return null;
}
return match ($type) {
'volume' => $this->application->persistentStorages()->whereKey((int) $id)->first(),
'directory' => $this->application->fileStorages()
->whereKey((int) $id)
->where('is_directory', true)
->where('is_host_file', false)
->first(),
default => null,
};
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Index extends Component
{
use AuthorizesRequests;
public Application $application;
public array $parameters;
public string $search = '';
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
public function mount(): void
{
$this->application = $this->findApplication();
$this->authorize('view', $this->application);
$this->parameters = get_route_parameters();
$this->search = request()->string('search')->toString();
}
public function render(): View
{
$backups = ScheduledVolumeBackup::query()
->with(['backupable', 'latestExecution', 's3'])
->withCount('executions')
->forApplication($this->application)
->latest()
->get();
return view('livewire.project.application.backup.index', ['backups' => $backups]);
}
private function findApplication(): Application
{
$project = currentTeam()->projects()
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
->where('uuid', request()->route('environment_uuid'))
->firstOrFail();
return $environment->applications()
->with('destination.server')
->where('uuid', request()->route('application_uuid'))
->firstOrFail();
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Application $application;
public ScheduledVolumeBackup $backup;
public array $parameters;
public string $section = 'general';
public function mount(): void
{
$project = currentTeam()->projects()
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
->where('uuid', request()->route('environment_uuid'))
->firstOrFail();
$this->application = $environment->applications()
->with('destination.server')
->where('uuid', request()->route('application_uuid'))
->firstOrFail();
$this->authorize('view', $this->application);
$this->backup = ScheduledVolumeBackup::query()
->with('backupable')
->where('uuid', request()->route('backup_uuid'))
->forApplication($this->application)
->firstOrFail();
$this->parameters = get_route_parameters();
$this->section = match (request()->route()?->getName()) {
'project.application.backup.s3' => 's3',
'project.application.backup.retention' => 'retention',
'project.application.backup.executions' => 'executions',
'project.application.backup.danger' => 'danger',
default => 'general',
};
}
public function render()
{
return view('livewire.project.application.backup.show');
}
}
@@ -24,7 +24,7 @@ class Configuration extends Component
public function mount()
{
$this->currentRoute = request()->route()->getName();
$this->syncCurrentRoute();
$project = currentTeam()
->projects()
@@ -36,10 +36,14 @@ class Configuration extends Component
->where('uuid', request()->route('environment_uuid'))
->firstOrFail();
$application = $environment->applications()
->with(['destination'])
->with(['destination.server', 'environment.project'])
->where('uuid', request()->route('application_uuid'))
->firstOrFail();
// Parent page already resolved these; keep them on the model for nested components.
$application->setRelation('environment', $environment);
$environment->setRelation('project', $project);
$this->project = $project;
$this->environment = $environment;
$this->application = $application;
@@ -49,8 +53,23 @@ class Configuration extends Component
}
}
/**
* Keep sidebar active state in sync on full-page navigations.
* Ignore Livewire update requests so poll/refresh does not clear it.
*/
protected function syncCurrentRoute(): void
{
$routeName = request()->route()?->getName();
if (is_string($routeName) && str_starts_with($routeName, 'project.application.')) {
$this->currentRoute = $routeName;
}
}
public function render()
{
$this->syncCurrentRoute();
return view('livewire.project.application.configuration');
}
}
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Application\Deployment;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -28,6 +30,24 @@ class Index extends Component
public ?string $pull_request_id = null;
public array $pullRequestOptions = [];
public string $search = '';
public array $deploymentFilters = [];
public string $deploymentSort = 'newest';
public array $statusFilterOptions = [];
public array $sourceFilterOptions = [];
public array $serverFilterOptions = [];
public bool $embedded = false;
public ?string $selectedDeploymentUuid = null;
protected $queryString = ['pull_request_id'];
public function getListeners()
@@ -64,11 +84,26 @@ class Index extends Component
}
}
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
$this->application = $application;
if ($this->embedded) {
$this->defaultTake = 3;
}
$this->loadPullRequestOptions();
$this->loadDeploymentFilterOptions();
['deployments' => $deployments, 'count' => $count] = $application->deployments(
search: $this->search,
filters: $this->deploymentFilters,
sort: $this->deploymentSort,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->current_url = url()->current();
$this->current_url = route('project.application.deployment.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'application_uuid' => $application->uuid,
]);
$this->updateCurrentPage();
$this->showMore();
}
@@ -90,38 +125,94 @@ class Index extends Component
$this->loadDeployments();
}
public function previousPage(?int $take = null)
public function previousPage(): void
{
if ($take) {
$this->skip = $this->skip - $take;
}
$this->skip = $this->skip - $this->defaultTake;
if ($this->skip < 0) {
$this->showPrev = false;
$this->skip = 0;
}
$this->skip = max(0, $this->skip - $this->defaultTake);
$this->showPrev = $this->skip > 0;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function nextPage(?int $take = null)
public function nextPage(): void
{
if ($take) {
$this->skip = $this->skip + $take;
}
$this->showPrev = true;
$this->goToPage($this->currentPage + 1);
}
public function goToPage(int $page): void
{
$lastPage = max(1, (int) ceil($this->deployments_count / $this->defaultTake));
$page = max(1, min($page, $lastPage));
$this->skip = ($page - 1) * $this->defaultTake;
$this->showPrev = $page > 1;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function loadDeployments()
{
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id);
['deployments' => $deployments, 'count' => $count] = $this->application->deployments(
skip: $this->skip,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
search: $this->search,
filters: $this->deploymentFilters,
sort: $this->deploymentSort,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->showMore();
}
public function updatedSearch(): void
{
$this->resetPaginationAndLoad();
}
public function toggleDeploymentFilter(string $filter): void
{
$validFilters = collect($this->statusFilterOptions)
->concat($this->sourceFilterOptions)
->concat($this->serverFilterOptions)
->pluck('value')
->values();
if (! $validFilters->contains($filter)) {
return;
}
if (in_array($filter, $this->deploymentFilters, true)) {
$this->deploymentFilters = array_values(array_diff($this->deploymentFilters, [$filter]));
} else {
$this->deploymentFilters[] = $filter;
}
$this->pull_request_id = null;
$this->resetPaginationAndLoad();
}
public function setPullRequestFilter(string $pullRequestId): void
{
$validPullRequestIds = collect($this->pullRequestOptions)->pluck('value');
if (! $validPullRequestIds->contains($pullRequestId)) {
return;
}
$this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId;
$this->deploymentFilters = [];
$this->resetPaginationAndLoad();
}
public function setDeploymentSort(string $sort): void
{
if (! in_array($sort, ['newest', 'oldest'], true)) {
return;
}
$this->deploymentSort = $sort;
$this->resetPaginationAndLoad();
}
public function updatedPullRequestId($value)
{
// Sanitize and validate the pull request ID
@@ -139,20 +230,15 @@ class Index extends Component
$this->pull_request_id = null;
}
// Reset pagination when filter changes
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilters = [];
$this->resetPaginationAndLoad();
}
public function clearFilter()
{
$this->pull_request_id = null;
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilters = [];
$this->resetPaginationAndLoad();
}
private function updateCurrentPage()
@@ -160,6 +246,116 @@ class Index extends Component
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
}
private function loadPullRequestOptions(): void
{
$pullRequestIds = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->where('pull_request_id', '>', 0)
->distinct()
->orderByDesc('pull_request_id')
->pluck('pull_request_id')
->map(fn ($pullRequestId) => (string) $pullRequestId)
->values();
if ($this->pull_request_id && ! $pullRequestIds->contains($this->pull_request_id)) {
$this->pull_request_id = null;
}
$this->pullRequestOptions = collect([
['value' => '', 'label' => 'All deployments'],
])
->concat($pullRequestIds->map(fn ($pullRequestId) => [
'value' => $pullRequestId,
'label' => "Pull request #{$pullRequestId}",
]))
->all();
}
private function loadDeploymentFilterOptions(): void
{
$statuses = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->distinct()
->pluck('status');
$statusLabels = [
ApplicationDeploymentStatus::FINISHED->value => 'Success',
ApplicationDeploymentStatus::FAILED->value => 'Failed',
ApplicationDeploymentStatus::IN_PROGRESS->value => 'In progress',
ApplicationDeploymentStatus::QUEUED->value => 'Queued',
ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'Cancelled',
];
$this->statusFilterOptions = collect($statusLabels)
->filter(fn (string $label, string $status) => $statuses->contains($status))
->map(fn (string $label, string $status) => [
'value' => "status:{$status}",
'label' => $label,
])
->values()
->all();
$sourceKeys = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->selectRaw("
CASE
WHEN pull_request_id > 0 THEN 'pull-request'
WHEN is_webhook THEN 'webhook'
WHEN rollback THEN 'rollback'
WHEN is_api THEN 'api'
ELSE 'manual'
END AS source_key
")
->distinct()
->pluck('source_key');
$sourceLabels = [
'manual' => 'Manual',
'pull-request' => 'Pull requests',
'webhook' => 'Webhooks',
'api' => 'API',
'rollback' => 'Rollbacks',
];
$this->sourceFilterOptions = collect($sourceLabels)
->filter(fn (string $label, string $source) => $sourceKeys->contains($source))
->map(fn (string $label, string $source) => [
'value' => "source:{$source}",
'label' => $label,
])
->values()
->all();
$servers = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->whereNotNull('server_id')
->select(['server_id', 'server_name'])
->distinct()
->orderBy('server_name')
->get()
->unique('server_id');
$this->serverFilterOptions = $servers
->map(function (ApplicationDeploymentQueue $deployment): array {
$serverId = (int) $deployment->server_id;
return [
'value' => "server:{$serverId}",
'label' => $deployment->server_name ?: "Server #{$serverId}",
];
})
->values()
->all();
}
private function resetPaginationAndLoad(): void
{
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function render()
{
return view('livewire.project.application.deployment.index');
File diff suppressed because it is too large. Load diff
+85 -28
View File
@@ -97,6 +97,9 @@ class General extends Component
public bool $isSpa = false;
/** UI-only aggregate of isStatic/isSpa: dynamic | static | spa */
public string $siteType = 'dynamic';
public bool $isBuildServerEnabled = false;
public bool $isPreserveRepositoryEnabled = false;
@@ -306,12 +309,7 @@ class General extends Component
}
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
$this->customLabels = $this->application->parseContainerLabels();
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
@@ -449,6 +447,7 @@ class General extends Component
// Application settings properties
$this->isStatic = $this->application->settings->is_static;
$this->isSpa = $this->application->settings->is_spa;
$this->siteType = $this->isStatic ? ($this->isSpa ? 'spa' : 'static') : 'dynamic';
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
@@ -456,6 +455,13 @@ class General extends Component
}
}
public function setSiteType(): void
{
$this->isStatic = $this->siteType !== 'dynamic';
$this->isSpa = $this->siteType === 'spa';
$this->instantSave();
}
public function instantSave()
{
try {
@@ -527,12 +533,7 @@ class General extends Component
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
@@ -558,22 +559,14 @@ class General extends Component
$uuid = new_public_id();
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedKey = normalizeComposeServiceName($serviceName);
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
// Convert back to original service names for storage
$originalDomains = [];
$composeServiceNames = collect(data_get($this->parsedServices, 'services', []))->keys()->map(fn ($name) => (string) $name)->values()->all();
foreach ($this->parsedServiceDomains as $key => $value) {
// Find the original service name by checking parsed services
$originalServiceName = $key;
if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) {
$originalServiceName = $originalName;
break;
}
}
}
$originalServiceName = findComposeServiceName((string) $key, $composeServiceNames) ?? (string) $key;
$originalDomains[$originalServiceName] = $value;
}
@@ -705,7 +698,10 @@ class General extends Component
if ($this->application->additional_servers->count() === 0) {
foreach ($domains as $domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
@@ -767,7 +763,10 @@ class General extends Component
$this->application->redirect = $this->redirect;
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
$server = $this->application->destination?->server;
$target = $server ? (serverDnsTargetIp($server) ?? $server->ip) : null;
$dnsHint = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', "You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list ({$dnsHint}).");
return;
}
@@ -885,13 +884,17 @@ class General extends Component
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
$originalDomains = $this->composeDomainsForStorage();
$this->application->docker_compose_domains = json_encode($originalDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
foreach ($originalDomains as $service) {
$domain = data_get($service, 'domain');
if ($domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
@@ -1012,4 +1015,58 @@ class General extends Component
'{workdir}/.env'
);
}
private function composeDomainsForStorage(): array
{
return rekeyComposeDomainsToServiceNames(
$this->parsedServiceDomains,
collect(data_get($this->parsedServices, 'services', []))->keys(),
);
}
/**
* Collapse domain map keys to underscore form keys for Livewire/HTML binding.
* When twin keys exist, prefer a filled domain over a blank one.
*
* @param array<string, mixed> $domains
* @return array<string, mixed>
*/
private function sanitizeParsedServiceDomainsForForm(array $domains): array
{
$sanitizedDomains = [];
foreach ($domains as $serviceName => $domain) {
$sanitizedKey = normalizeComposeServiceName((string) $serviceName);
if (! array_key_exists($sanitizedKey, $sanitizedDomains)) {
$sanitizedDomains[$sanitizedKey] = $domain;
continue;
}
$existing = $sanitizedDomains[$sanitizedKey];
if (is_object($existing)) {
$existing = (array) $existing;
}
if (is_object($domain)) {
$domain = (array) $domain;
}
if (! is_array($existing)) {
$existing = ['domain' => $existing];
}
if (! is_array($domain)) {
$domain = ['domain' => $domain];
}
$merged = array_merge($existing, $domain);
$merged['domain'] = preferComposeDomainValue(
$existing['domain'] ?? null,
false,
$domain['domain'] ?? null,
false,
);
$sanitizedDomains[$sanitizedKey] = $merged;
}
return $sanitizedDomains;
}
}
+23 -4
View File
@@ -20,6 +20,8 @@ class Heading extends Component
public array $parameters;
public string $activeRouteName = '';
protected string $deploymentUuid;
public bool $docker_cleanup = true;
@@ -38,6 +40,7 @@ class Heading extends Component
public function mount()
{
$this->syncActiveRouteName();
$this->parameters = [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
@@ -48,6 +51,20 @@ class Heading extends Component
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit'));
}
/**
* Keep the active tab in sync with the real page route.
* Only update when the request is a full page route (not livewire.update),
* so wire:poll re-renders do not wipe the highlighted tab.
*/
protected function syncActiveRouteName(): void
{
$routeName = request()->route()?->getName();
if (is_string($routeName) && str_starts_with($routeName, 'project.application.')) {
$this->activeRouteName = $routeName;
}
}
public function checkStatus()
{
if ($this->application->destination->server->isFunctional()) {
@@ -115,12 +132,12 @@ class Heading extends Component
return;
}
return $this->redirectRoute('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -172,12 +189,12 @@ class Heading extends Component
return;
}
return $this->redirectRoute('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -185,6 +202,8 @@ class Heading extends Component
public function render()
{
$this->syncActiveRouteName();
return view('livewire.project.application.heading', [
'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
@@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class InternalAccess extends Component
{
public Application $application;
public ?string $currentInternalHostname = null;
public bool $currentInternalHostnameLoaded = false;
public function loadCurrentInternalHostname(): void
{
try {
$containers = getCurrentApplicationContainerStatus(
$this->application->destination->server,
$this->application->id,
0
);
$currentContainer = $containers->first(
fn ($container) => data_get($container, 'State') === 'running'
) ?? $containers->first();
$this->currentInternalHostname = data_get($currentContainer, 'Names');
} catch (\Throwable) {
$this->currentInternalHostname = null;
} finally {
$this->currentInternalHostnameLoaded = true;
}
}
public function render(): View
{
return view('livewire.project.application.internal-access');
}
}
@@ -129,7 +129,10 @@ class Previews extends Component
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
@@ -39,13 +39,7 @@ class PreviewsCompose extends Component
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
$docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
$docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain saved.');
} catch (\Throwable $e) {
@@ -58,12 +52,8 @@ class PreviewsCompose extends Component
try {
$this->authorize('update', $this->preview->application);
$domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []);
$domain = $domains->first(function ($_, $key) {
return $key === $this->serviceName;
});
$domain_string = data_get($domain, 'domain');
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
// If no domain is set in the main application, generate a default domain
if (empty($domain_string)) {
@@ -111,14 +101,8 @@ class PreviewsCompose extends Component
$preview_fqdn = implode(',', $preview_fqdns);
}
// Save the generated domain
$this->domain = $preview_fqdn;
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
$docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
$docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
@@ -126,4 +110,56 @@ class PreviewsCompose extends Component
return handleError($e, $this);
}
}
private function persistPreviewDomain(?string $domain): void
{
$docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: [];
$serviceNames = $this->previewServiceNames($docker_compose_domains);
$storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames)
?? (string) $this->serviceName;
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$storageKey,
$domain,
$serviceNames,
);
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
$this->serviceName = $storageKey;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
}
/**
* @param array<string, mixed> $previewDomains
* @return list<string>
*/
private function previewServiceNames(array $previewDomains): array
{
$parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
$fromCompose = collect(data_get($parsedServices, 'services', []))
->keys()
->map(function ($serviceName) {
return str((string) $serviceName)
->replaceLast('-pr-'.$this->preview->pull_request_id, '')
->toString();
})
->all();
$domainKeys = collect(array_keys($previewDomains))
->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []))
->map(fn ($name) => (string) $name);
$unmapped = $domainKeys
->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null)
->all();
return collect($fromCompose)
->merge(preferredComposeServiceNamesFromDomainKeys(
$fromCompose === [] ? $domainKeys->all() : $unmapped
))
->unique()
->values()
->all();
}
}
+28 -11
View File
@@ -96,9 +96,19 @@ class Source extends Component
private function getSources()
{
// filter the current source out
$this->sources = currentTeam()->sources()->whereNotNull('app_id')->reject(function ($source) {
return $source->id === $this->application->source_id;
$this->sources = currentTeam()->sources()->filter(function ($source) {
if ($source->id === $this->application->source_id
&& $source->getMorphClass() === $this->application->source_type) {
return false;
}
if ($source instanceof GithubApp) {
return ! is_null($source->app_id);
}
if ($source instanceof GitlabApp) {
return $source->isConnected();
}
return true;
})->sortBy('name');
}
@@ -137,7 +147,6 @@ class Source extends Component
public function changeSource($sourceId, $sourceType)
{
try {
$this->authorize('update', $this->application);
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
@@ -150,16 +159,24 @@ class Source extends Component
$this->dispatch('configurationChanged');
['repository' => $customRepository] = $this->application->customRepository();
$repository = githubApi($this->application->source, "repos/{$customRepository}");
$data = data_get($repository, 'data');
$repository_project_id = data_get($data, 'id');
if (isset($repository_project_id)) {
if ($this->application->repository_project_id !== $repository_project_id) {
$this->application->repository_project_id = $repository_project_id;
$this->application->save();
$repository_project_id = null;
if ($sourceType === GithubApp::class) {
$repository = githubApi($source, "repos/{$customRepository}");
$repository_project_id = data_get($repository, 'data.id');
} elseif ($sourceType === GitlabApp::class) {
if ($source->isConnected()) {
$encoded = urlencode($customRepository);
$project = gitlabApi($source, "/projects/{$encoded}");
$repository_project_id = data_get($project, 'data.id');
}
}
if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) {
$this->application->repository_project_id = $repository_project_id;
$this->application->save();
}
$this->application->refresh();
$this->getSources();
$this->dispatch('success', 'Source updated!');
@@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Status extends Component
{
public Application $application;
public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->application->refresh();
}
public function render(): View
{
return view('livewire.project.application.status');
}
}