feat(ui): polish domains, storage, env vars and resource nav

Improve project resource UIs: sort domains by DNS failure, stop re-adding www pairs on refresh, lazy-load storage tabs with counts, tighten env-var tables, keep application tabs active across Livewire polls, unify database type labels, and update related CSS/JS and tests.
This commit is contained in:
Andras Bacsai 2026-08-05 13:50:11 +02:00
parent dbc6f5e08c
commit a7a06aa6c8
75 changed files with 3909 additions and 1173 deletions

View file

@ -38,6 +38,32 @@ protected function rules(): array
public function mount(): void public function mount(): void
{ {
$this->authorize('view', $this->application); $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() $volumes = $this->application->persistentStorages()
->orderBy('name') ->orderBy('name')
->get() ->get()
@ -57,7 +83,6 @@ public function mount(): void
'name' => $directory->fs_path, 'name' => $directory->fs_path,
]); ]);
$this->targets = $volumes->concat($directories)->values(); $this->targets = $volumes->concat($directories)->values();
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key'); $this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup(); $this->loadSelectedBackup();
} }

View file

@ -24,7 +24,7 @@ class Configuration extends Component
public function mount() public function mount()
{ {
$this->currentRoute = request()->route()->getName(); $this->syncCurrentRoute();
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
@ -36,10 +36,14 @@ public function mount()
->where('uuid', request()->route('environment_uuid')) ->where('uuid', request()->route('environment_uuid'))
->firstOrFail(); ->firstOrFail();
$application = $environment->applications() $application = $environment->applications()
->with(['destination']) ->with(['destination.server', 'environment.project'])
->where('uuid', request()->route('application_uuid')) ->where('uuid', request()->route('application_uuid'))
->firstOrFail(); ->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->project = $project;
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
@ -49,8 +53,23 @@ public function mount()
} }
} }
/**
* 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() public function render()
{ {
$this->syncCurrentRoute();
return view('livewire.project.application.configuration'); return view('livewire.project.application.configuration');
} }
} }

View file

@ -200,6 +200,10 @@ public function loadDomainState(): void
} }
} }
// Do not auto-promote www/non-www pairs here: load/refresh also runs after
// removeDomain, and re-adding counterparts would undo intentional deletes.
// Pairs are still ensured on setRedirect, addDomain, and generateDomain.
$this->domainRows = $this->buildDomainRows(); $this->domainRows = $this->buildDomainRows();
} }
@ -269,14 +273,26 @@ protected function buildDomainRows(): array
} }
} }
return $rows; return $this->sortDomainRowsByDnsStatus($rows);
} }
foreach ($this->splitDomains($this->application->fqdn) as $url) { foreach ($this->splitDomains($this->application->fqdn) as $url) {
$rows[] = $this->domainRowFromStored($url, null, $stored); $rows[] = $this->domainRowFromStored($url, null, $stored);
} }
return array_merge($rows, $this->buildSuggestedWwwRows($rows, $stored)); return $this->sortDomainRowsByDnsStatus(array_merge($rows, $this->buildSuggestedWwwRows($rows, $stored)));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
protected function sortDomainRowsByDnsStatus(array $rows): array
{
return collect($rows)
->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1)
->values()
->all();
} }
/** /**
@ -327,19 +343,10 @@ protected function buildSuggestedWwwRows(array $configuredRows, array $stored, ?
$base['is_suggested'] = true; $base['is_suggested'] = true;
$base['suggested_for'] = $url; $base['suggested_for'] = $url;
$base['suggestion_label'] = $meta['label']; $base['suggestion_label'] = null;
$base['suggestion_role'] = $meta['role']; $base['suggestion_role'] = $meta['role'];
$base['needs_force_add'] = false; $base['needs_force_add'] = false;
// Always show role-specific guidance for suggested rows (even after DNS checks).
if (($base['dns_status'] ?? 'pending') === 'pending') {
$base['dns_message'] = $meta['pending_message']; $base['dns_message'] = $meta['pending_message'];
} elseif (in_array($base['dns_status'], ['ok', 'failed', 'skipped'], true)) {
// Keep stored DNS result message, but append role context when redirect is set.
if ($meta['role'] !== 'pair' && ! str_contains((string) $base['dns_message'], 'redirect')) {
$base['dns_message'] = trim((string) $base['dns_message'].' '.$meta['dns_suffix']);
}
}
$suggested[] = $base; $suggested[] = $base;
} }
@ -354,41 +361,40 @@ protected function buildSuggestedWwwRows(array $configuredRows, array $stored, ?
*/ */
protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array
{ {
$pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp); $pendingMessage = 'Not configured yet.';
$redirect = $redirectOverride ?? ($this->redirect ?: 'both'); $redirect = $redirectOverride ?? ($this->redirect ?: 'both');
return match ($redirect) { return match ($redirect) {
'www' => $suggestedIsWww 'www' => $suggestedIsWww
? [ ? [
'label' => 'Canonical www', 'label' => 'Not added · canonical www',
'role' => 'canonical', 'role' => 'canonical',
'pending_message' => "Required as the redirect target (www). {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'This is the canonical www host traffic should land on.', 'dns_suffix' => '',
] ]
: [ : [
'label' => 'Redirect source', 'label' => 'Not added · redirect source',
'role' => 'redirect_source', 'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.', 'dns_suffix' => '',
], ],
'non-www' => $suggestedIsWww 'non-www' => $suggestedIsWww
? [ ? [
'label' => 'Redirect source', 'label' => 'Not added · redirect source',
'role' => 'redirect_source', 'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'Used only so Coolify can redirect this host to non-www. Still needs DNS to the server, not a provider URL-redirect record.', 'dns_suffix' => '',
] ]
: [ : [
'label' => 'Canonical non-www', 'label' => 'Not added · canonical non-www',
'role' => 'canonical', 'role' => 'canonical',
'pending_message' => "Required as the redirect target (non-www). {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'This is the canonical non-www host traffic should land on.', 'dns_suffix' => '',
], ],
default => [ default => [
'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www', 'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www',
'role' => 'pair', 'role' => 'pair',
'pending_message' => "Also add this host so both www and non-www work. {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => '', 'dns_suffix' => '',
], ],
}; };
@ -546,7 +552,7 @@ protected function applyDnsStatus(int $index, string $url, Server $server): void
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
} }
// Clarify purpose for redirect-source / canonical suggested hosts. // Keep suggested-row copy short after DNS checks (no role badge).
if ($this->domainRows[$index]['is_suggested'] ?? false) { if ($this->domainRows[$index]['is_suggested'] ?? false) {
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$serviceName = $this->domainRows[$index]['service'] ?? null; $serviceName = $this->domainRows[$index]['service'] ?? null;
@ -554,10 +560,8 @@ protected function applyDnsStatus(int $index, string $url, Server $server): void
$isWww, $isWww,
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
); );
if ($meta['dns_suffix'] !== '') { $this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['dns_message'] = trim($this->domainRows[$index]['dns_message'].' '.$meta['dns_suffix']); $this->domainRows[$index]['suggestion_label'] = null;
}
$this->domainRows[$index]['suggestion_label'] = $meta['label'];
$this->domainRows[$index]['suggestion_role'] = $meta['role']; $this->domainRows[$index]['suggestion_role'] = $meta['role'];
} }
@ -727,6 +731,11 @@ public function addDomain(): void
} }
$newUrls = $this->splitDomains($normalized); $newUrls = $this->splitDomains($normalized);
$pairedUrls = collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all();
$current = $this->currentDomainList($this->newDomainService); $current = $this->currentDomainList($this->newDomainService);
foreach ($newUrls as $url) { foreach ($newUrls as $url) {
@ -747,10 +756,9 @@ public function addDomain(): void
} }
} }
$merged = $current->merge($newUrls)->unique()->values(); $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add'; $this->pendingAction = 'add';
// DNS was already validated (or overridden) in the modal; skip save-time toast noise. if (! $this->saveDomainList($merged, $this->newDomainService)) {
if (! $this->saveDomainList($merged, $this->newDomainService, checkDns: false)) {
return; return;
} }
@ -761,7 +769,7 @@ public function addDomain(): void
$this->dispatch('close-modal'); $this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.'); $this->dispatch('success', 'Domain added.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns($newUrls, $serviceForCheck); $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck);
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
} }
@ -931,7 +939,6 @@ public function addSuggestedDomain(int $index): void
$this->forceAddSuggestedIndex = $index; $this->forceAddSuggestedIndex = $index;
$this->editingIndex = $index; $this->editingIndex = $index;
$this->persistDomainDnsStatuses(); $this->persistDomainDnsStatuses();
$this->dispatch('error', 'DNS validation failed.', $dnsFailure);
return; return;
} }
@ -940,7 +947,7 @@ public function addSuggestedDomain(int $index): void
$merged = $current->merge($newUrls)->unique()->values(); $merged = $current->merge($newUrls)->unique()->values();
$this->pendingAction = 'suggested'; $this->pendingAction = 'suggested';
$this->editingIndex = $index; $this->editingIndex = $index;
if (! $this->saveDomainList($merged, $serviceName, checkDns: false)) { if (! $this->saveDomainList($merged, $serviceName)) {
return; return;
} }
@ -1017,6 +1024,7 @@ public function updateDomain(): void
if ($dnsFailure !== null) { if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true; $this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure); $this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
$this->showEditDomainModal = true;
return; return;
} }
@ -1024,7 +1032,7 @@ public function updateDomain(): void
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update'; $this->pendingAction = 'update';
if (! $this->saveDomainList($updated, $service, checkDns: false)) { if (! $this->saveDomainList($updated, $service)) {
return; return;
} }
@ -1058,7 +1066,7 @@ public function removeDomain(int $index): void
$service = $this->domainRows[$index]['service']; $service = $this->domainRows[$index]['service'];
$updated = $this->currentDomainList($service)->reject(fn (string $item) => $item === $url)->values(); $updated = $this->currentDomainList($service)->reject(fn (string $item) => $item === $url)->values();
if (! $this->saveDomainList($updated, $service, checkConflicts: false, checkDns: false)) { if (! $this->saveDomainList($updated, $service, checkConflicts: false)) {
return; return;
} }
@ -1104,26 +1112,32 @@ public function generateDomain(?string $serviceName = null): void
$current = $this->currentDomainList($serviceName); $current = $this->currentDomainList($serviceName);
$merged = $current->push($domain)->unique()->values(); $merged = $current->push($domain)->unique()->values();
if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false, checkDns: false)) { if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false)) {
return; return;
} }
$pairedUrls = $this->syncRedirectDomainPairs($serviceName);
$this->resetAddDomainForm(); $this->resetAddDomainForm();
$this->dispatch('close-modal'); $this->dispatch('close-modal');
$this->dispatch('success', 'Domain generated.'); $this->dispatch('success', 'Domain generated.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge([$domain], $pairedUrls))), $serviceName);
return; return;
} }
$fqdn = generateUrl(server: $server, random: $this->application->uuid); $fqdn = generateUrl(server: $server, random: $this->application->uuid);
$this->application->fqdn = $fqdn; $merged = $this->currentDomainList()->push($fqdn)->unique()->values();
$this->application->save(); if (! $this->saveDomainList($merged, null, checkConflicts: false)) {
$this->resetDefaultLabels(); return;
}
$pairedUrls = $this->syncRedirectDomainPairs(null);
$this->resetAddDomainForm(); $this->resetAddDomainForm();
$this->dispatch('close-modal'); $this->dispatch('close-modal');
$this->dispatch('success', 'Domain generated.'); $this->dispatch('success', 'Domain generated.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge([$fqdn], $pairedUrls))));
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
} }
@ -1298,6 +1312,74 @@ function ($fqdn) {
return true; return true;
} }
/**
* When saved redirect is www/non-www, ensure missing counterparts exist as real domains
* (not suggestion rows the user must click Add domain for).
*
* @return array<int, string> newly added domain URLs
*/
protected function syncRedirectDomainPairs(?string $serviceName = null): array
{
if ($this->labelsAreWritable) {
return [];
}
$user = auth()->user();
if ($user === null || ! $user->can('update', $this->application)) {
return [];
}
if ($this->isCompose && $serviceName === null) {
$added = [];
$serviceNames = $this->composeServices;
$domains = $this->application->docker_compose_domains
? json_decode($this->application->docker_compose_domains, true)
: [];
if (is_array($domains)) {
foreach (array_keys($domains) as $name) {
if (! in_array($name, $serviceNames, true)) {
$serviceNames[] = $name;
}
}
}
foreach ($serviceNames as $name) {
$added = array_merge($added, $this->syncRedirectDomainPairs($name));
}
return array_values(array_unique($added));
}
$redirect = $this->savedRedirectForService($serviceName);
if (! in_array($redirect, ['www', 'non-www'], true)) {
return [];
}
$before = $this->currentDomainList($serviceName)->all();
if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) {
return [];
}
$this->application->refresh();
$after = $this->currentDomainList($serviceName);
return $after->reject(fn (string $url) => in_array($url, $before, true))->values()->all();
}
protected function savedRedirectForService(?string $serviceName): string
{
if ($this->isCompose && filled($serviceName)) {
$domains = $this->application->docker_compose_domains
? json_decode($this->application->docker_compose_domains, true)
: [];
$entry = is_array($domains) ? ($domains[$serviceName] ?? null) : null;
$stored = is_array($entry) ? ($entry['redirect'] ?? null) : null;
return $this->normalizeRedirect(is_string($stored) ? $stored : null);
}
return $this->normalizeRedirect($this->application->redirect ?? null);
}
/** /**
* Persist missing www/non-www counterparts as normal domains (not suggestions). * Persist missing www/non-www counterparts as normal domains (not suggestions).
* *
@ -1346,10 +1428,13 @@ protected function ensureWwwNonWwwPairsConfigured(?string $serviceName = null):
$this->pendingRedirectService = $serviceName; $this->pendingRedirectService = $serviceName;
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready. // Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
if (! $this->saveDomainList($merged, $serviceName, checkDns: false)) { if (! $this->saveDomainList($merged, $serviceName)) {
return false; return false;
} }
$this->pendingAction = null;
$this->pendingRedirectService = null;
return true; return true;
} }
@ -1446,7 +1531,6 @@ protected function saveDomainList(
Collection $domains, Collection $domains,
?string $serviceName = null, ?string $serviceName = null,
bool $checkConflicts = true, bool $checkConflicts = true,
bool $checkDns = true,
): bool { ): bool {
$domainString = $domains->filter()->unique()->implode(','); $domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@ -1488,25 +1572,6 @@ protected function saveDomainList(
$this->application->fqdn = $domainString; $this->application->fqdn = $domainString;
} }
if ($checkDns && $domainString && $this->application->additional_servers->count() === 0) {
$server = $this->application->destination?->server;
if ($server) {
foreach ($this->splitDomains($domainString) as $domain) {
if (! validateDNSEntry($domain, $server)) {
$guidance = dnsMismatchGuidanceMessage(
$this->dnsTargetLabel() ?? serverDnsTargetIp($server) ?? $server->ip,
$this->serverIp ?? serverDnsTargetIp($server) ?? $server->ip,
);
$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."
);
}
}
}
}
if ($checkConflicts && ! $this->forceSaveDomains) { if ($checkConflicts && ! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application); $result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) { if ($result['hasConflicts']) {

View file

@ -40,7 +40,7 @@ public function getListeners()
public function mount() public function mount()
{ {
$this->activeRouteName = request()->route()?->getName() ?? ''; $this->syncActiveRouteName();
$this->parameters = [ $this->parameters = [
'project_uuid' => $this->application->project()->uuid, 'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid, 'environment_uuid' => $this->application->environment->uuid,
@ -51,6 +51,20 @@ public function mount()
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit')); $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() public function checkStatus()
{ {
if ($this->application->destination->server->isFunctional()) { if ($this->application->destination->server->isFunctional()) {
@ -188,6 +202,8 @@ public function restart()
public function render() public function render()
{ {
$this->syncActiveRouteName();
return view('livewire.project.application.heading', [ return view('livewire.project.application.heading', [
'checkboxes' => [ 'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],

View file

@ -189,14 +189,14 @@ public function render()
'clickhouses' => $this->clickhouses, 'clickhouses' => $this->clickhouses,
'services' => $this->services, 'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'), 'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'), 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'Database'),
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'), 'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Database'),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'), 'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'Database'),
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'), 'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'Database'),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'), 'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'Database'),
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'), 'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'Database'),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'), 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Database'),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'), 'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'Database'),
'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'), 'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'),
]); ]);
} }

View file

@ -158,6 +158,10 @@ public function loadDomainState(): void
$this->newServiceApplicationId = $this->serviceApps[0]['id']; $this->newServiceApplicationId = $this->serviceApps[0]['id'];
} }
// Do not auto-promote www/non-www pairs here: load/refresh also runs after
// removeDomain, and re-adding counterparts would undo intentional deletes.
// Pairs are still ensured on setServiceRedirect, addDomain, etc.
$this->domainRows = $this->buildDomainRows(); $this->domainRows = $this->buildDomainRows();
} }
@ -197,7 +201,10 @@ protected function buildDomainRows(): array
} }
} }
return $rows; return collect($rows)
->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1)
->values()
->all();
} }
/** /**
@ -282,17 +289,10 @@ protected function buildSuggestedWwwRows(array $configuredRows, ServiceApplicati
$base['is_suggested'] = true; $base['is_suggested'] = true;
$base['suggested_for'] = $url; $base['suggested_for'] = $url;
$base['suggestion_label'] = $meta['label']; $base['suggestion_label'] = null;
$base['suggestion_role'] = $meta['role']; $base['suggestion_role'] = $meta['role'];
$base['needs_force_add'] = false; $base['needs_force_add'] = false;
if (($base['dns_status'] ?? 'pending') === 'pending') {
$base['dns_message'] = $meta['pending_message']; $base['dns_message'] = $meta['pending_message'];
} elseif (in_array($base['dns_status'], ['ok', 'failed', 'skipped'], true)) {
if ($meta['role'] !== 'pair' && ! str_contains((string) $base['dns_message'], 'redirect')) {
$base['dns_message'] = trim((string) $base['dns_message'].' '.$meta['dns_suffix']);
}
}
$suggested[] = $base; $suggested[] = $base;
} }
@ -381,6 +381,7 @@ public function checkDomainDns(int $index): void
$this->domainRows[$index]['dns_status'] = 'skipped'; $this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; $this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String(); $this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
$this->persistAllDomainDnsStatuses(); $this->persistAllDomainDnsStatuses();
return; return;
@ -412,6 +413,24 @@ protected function applyDnsStatus(int $index, string $url, Server $server): void
$this->domainRows[$index]['expected_ip'] = $this->serverIp; $this->domainRows[$index]['expected_ip'] = $this->serverIp;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String(); $this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
}
/**
* Keep suggested-row copy short after a DNS check (no role badge).
*/
protected function decorateSuggestedDomainAfterDnsCheck(int $index): void
{
if (! ($this->domainRows[$index]['is_suggested'] ?? false)) {
return;
}
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$appId = (int) ($this->domainRows[$index]['service_application_id'] ?? 0);
$meta = $this->suggestedDomainMeta($isWww, $this->serviceRedirectFor($appId > 0 ? $appId : null));
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
} }
protected function persistAllDomainDnsStatuses(): void protected function persistAllDomainDnsStatuses(): void
@ -525,40 +544,40 @@ public function confirmDomainUsage(): void
*/ */
protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array
{ {
$pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp); $pendingMessage = 'Not configured yet.';
$redirect = $this->normalizeRedirect($redirectOverride); $redirect = $this->normalizeRedirect($redirectOverride);
return match ($redirect) { return match ($redirect) {
'www' => $suggestedIsWww 'www' => $suggestedIsWww
? [ ? [
'label' => 'Canonical www', 'label' => 'Not added · canonical www',
'role' => 'canonical', 'role' => 'canonical',
'pending_message' => "Required as the redirect target (www). {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'This is the canonical www host traffic should land on.', 'dns_suffix' => '',
] ]
: [ : [
'label' => 'Redirect source', 'label' => 'Not added · redirect source',
'role' => 'redirect_source', 'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.', 'dns_suffix' => '',
], ],
'non-www' => $suggestedIsWww 'non-www' => $suggestedIsWww
? [ ? [
'label' => 'Redirect source', 'label' => 'Not added · redirect source',
'role' => 'redirect_source', 'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'Used only so Coolify can redirect this host to non-www. Still needs DNS to the server, not a provider URL-redirect record.', 'dns_suffix' => '',
] ]
: [ : [
'label' => 'Canonical non-www', 'label' => 'Not added · canonical non-www',
'role' => 'canonical', 'role' => 'canonical',
'pending_message' => "Required as the redirect target (non-www). {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => 'This is the canonical non-www host traffic should land on.', 'dns_suffix' => '',
], ],
default => [ default => [
'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www', 'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www',
'role' => 'pair', 'role' => 'pair',
'pending_message' => "Also add this host so both www and non-www work. {$pointDns}", 'pending_message' => $pendingMessage,
'dns_suffix' => '', 'dns_suffix' => '',
], ],
}; };
@ -663,6 +682,43 @@ function ($fqdn) {
return true; return true;
} }
/**
* When saved redirect is www/non-www, ensure missing counterparts exist as real domains.
*
* @return array<int, string> newly added domain URLs
*/
protected function syncRedirectDomainPairs(?ServiceApplication $app = null): array
{
$user = auth()->user();
if ($user === null || ! $user->can('update', $this->service)) {
return [];
}
if ($app === null) {
$added = [];
foreach ($this->service->applications as $serviceApp) {
$added = array_merge($added, $this->syncRedirectDomainPairs($serviceApp));
}
return array_values(array_unique($added));
}
$redirect = $this->normalizeRedirect($app->redirect ?? null);
if (! in_array($redirect, ['www', 'non-www'], true)) {
return [];
}
$before = collect($this->splitDomains($app->fqdn))->all();
if (! $this->ensureWwwNonWwwPairsConfigured($app)) {
return [];
}
$app->refresh();
$after = collect($this->splitDomains($app->fqdn));
return $after->reject(fn (string $url) => in_array($url, $before, true))->values()->all();
}
/** /**
* Persist missing www/non-www counterparts as normal domains (not suggestions). * Persist missing www/non-www counterparts as normal domains (not suggestions).
* *
@ -710,10 +766,13 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
$this->pendingRedirectServiceApplicationId = $app->id; $this->pendingRedirectServiceApplicationId = $app->id;
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready. // Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { if (! $this->saveDomainListForApp($app, $merged)) {
return false; return false;
} }
$this->pendingAction = null;
$this->pendingRedirectServiceApplicationId = null;
return true; return true;
} }
@ -766,6 +825,11 @@ public function addDomain(): void
} }
$newUrls = $this->splitDomains($normalized); $newUrls = $this->splitDomains($normalized);
$pairedUrls = collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all();
$current = collect($this->splitDomains($app->fqdn)); $current = collect($this->splitDomains($app->fqdn));
foreach ($newUrls as $url) { foreach ($newUrls as $url) {
if ($current->contains($url)) { if ($current->contains($url)) {
@ -785,10 +849,10 @@ public function addDomain(): void
} }
} }
$merged = $current->merge($newUrls)->unique()->values(); $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add'; $this->pendingAction = 'add';
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { if (! $this->saveDomainListForApp($app, $merged)) {
return; return;
} }
@ -802,7 +866,7 @@ public function addDomain(): void
$this->dispatch('close-modal'); $this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.'); $this->dispatch('success', 'Domain added.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns($newUrls, (int) $app->id); $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
} }
@ -874,6 +938,7 @@ public function updateDomain(): void
if ($dnsFailure !== null) { if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true; $this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = $dnsFailure; $this->editDomainDnsMessage = $dnsFailure;
$this->showEditDomainModal = true;
return; return;
} }
@ -882,7 +947,7 @@ public function updateDomain(): void
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update'; $this->pendingAction = 'update';
if (! $this->saveDomainListForApp($app, $updated, checkDns: false)) { if (! $this->saveDomainListForApp($app, $updated)) {
return; return;
} }
@ -917,7 +982,7 @@ public function removeDomain(int $index): void
$this->forceSaveDomains = true; $this->forceSaveDomains = true;
$this->forceRemovePort = true; $this->forceRemovePort = true;
if (! $this->saveDomainListForApp($app, $updated, checkDns: false, checkConflicts: false)) { if (! $this->saveDomainListForApp($app, $updated, checkConflicts: false)) {
return; return;
} }
@ -970,7 +1035,6 @@ public function addSuggestedDomain(int $index): void
$this->forceAddSuggestedIndex = $index; $this->forceAddSuggestedIndex = $index;
$this->editingIndex = $index; $this->editingIndex = $index;
$this->persistAllDomainDnsStatuses(); $this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'DNS validation failed.', $dnsFailure);
return; return;
} }
@ -980,7 +1044,7 @@ public function addSuggestedDomain(int $index): void
$this->pendingAction = 'suggested'; $this->pendingAction = 'suggested';
$this->editingIndex = $index; $this->editingIndex = $index;
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { if (! $this->saveDomainListForApp($app, $merged)) {
return; return;
} }
@ -1035,7 +1099,6 @@ public function generateDomain(): void
protected function saveDomainListForApp( protected function saveDomainListForApp(
ServiceApplication $app, ServiceApplication $app,
Collection $domains, Collection $domains,
bool $checkDns = true,
bool $checkConflicts = true, bool $checkConflicts = true,
): bool { ): bool {
$domainString = $domains->filter()->unique()->implode(','); $domainString = $domains->filter()->unique()->implode(',');
@ -1078,25 +1141,6 @@ protected function saveDomainListForApp(
} }
} }
if ($checkDns && $domainString && $this->shouldValidateDns()) {
$server = $this->service->server;
if ($server) {
foreach ($this->splitDomains($domainString) as $domain) {
if (! validateDNSEntry($domain, $server)) {
$guidance = dnsMismatchGuidanceMessage(
$this->dnsTargetLabel() ?? serverDnsTargetIp($server) ?? $server->ip,
$this->serverIp ?? serverDnsTargetIp($server) ?? $server->ip,
);
$this->dispatch(
'error',
'Validating DNS failed.',
$guidance
);
}
}
}
}
$warning = sslipDomainWarning($domainString ?? ''); $warning = sslipDomainWarning($domainString ?? '');
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -37,6 +37,14 @@ class Storage extends Component
public string $file_storage_directory_destination = ''; public string $file_storage_directory_destination = '';
public string $activeTab = 'volumes';
public int $cachedVolumeCount = 0;
public int $cachedFileCount = 0;
public int $cachedDirectoryCount = 0;
public function getListeners() public function getListeners()
{ {
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
@ -57,12 +65,18 @@ public function mount()
} }
if ($this->resource->getMorphClass() === Application::class) { if ($this->resource->getMorphClass() === Application::class) {
if ($this->resource->destination->server->isSwarm()) { $this->resource->loadMissing('destination.server', 'environment.project');
if ($this->resource->destination?->server?->isSwarm()) {
$this->isSwarm = true; $this->isSwarm = true;
} }
} }
$this->refreshStorages(); // Counts only on mount — child All (volumes) / file list load their own payloads.
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
} }
public function refreshStoragesFromEvent() public function refreshStoragesFromEvent()
@ -73,37 +87,110 @@ public function refreshStoragesFromEvent()
public function refreshStorages() public function refreshStorages()
{ {
$this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) { // Avoid loading full volume models onto this parent (child All owns that snapshot).
$this->resource->unsetRelation('persistentStorages');
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->loadFileStorageForActiveTab();
}
public function setActiveTab(string $tab): void
{
if (! in_array($tab, ['volumes', 'files', 'directories'], true)) {
return;
}
$this->activeTab = $tab;
$this->loadFileStorageForActiveTab();
}
private function resolveDefaultTab(): string
{
if ($this->volumeCount > 0) {
return 'volumes';
}
if ($this->fileCount > 0) {
return 'files';
}
if ($this->directoryCount > 0) {
return 'directories';
}
return 'volumes';
}
private function loadVolumeCount(): void
{
$this->cachedVolumeCount = $this->resource->persistentStorages()->count();
}
/**
* Counts only avoids loading file contents into the Livewire snapshot on the volumes tab.
*/
private function loadFileStorageMetaCounts(): void
{
$this->cachedFileCount = $this->resource->fileStorages()->where('is_directory', false)->count();
$this->cachedDirectoryCount = $this->resource->fileStorages()->where('is_directory', true)->count();
}
/**
* Load full file/directory mounts only for the active tab (content only on files).
*/
private function loadFileStorageForActiveTab(): void
{
if ($this->activeTab === 'volumes') {
// Keep snapshot small while the volumes tab is shown.
$this->fileStorage = collect();
return;
}
$query = $this->resource->fileStorages();
if ($this->activeTab === 'files') {
$query->where('is_directory', false);
} else {
$query->where('is_directory', true);
}
$this->fileStorage = $query->get()->each(function (LocalFileVolume $fs): void {
if ($this->activeTab !== 'files') {
$fs->content = null;
return;
}
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) { if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
} }
}); });
$this->resource->load('persistentStorages.resource');
} }
public function getFilesProperty() public function getFilesProperty()
{ {
return $this->fileStorage->where('is_directory', false); return collect($this->fileStorage)->where('is_directory', false);
} }
public function getDirectoriesProperty() public function getDirectoriesProperty()
{ {
return $this->fileStorage->where('is_directory', true); return collect($this->fileStorage)->where('is_directory', true);
} }
public function getVolumeCountProperty() public function getVolumeCountProperty()
{ {
return $this->resource->persistentStorages()->count(); return $this->cachedVolumeCount;
} }
public function getFileCountProperty() public function getFileCountProperty()
{ {
return $this->files->count(); return $this->cachedFileCount;
} }
public function getDirectoryCountProperty() public function getDirectoryCountProperty()
{ {
return $this->directories->count(); return $this->cachedDirectoryCount;
} }
public function submitPersistentVolume() public function submitPersistentVolume()
@ -130,12 +217,12 @@ public function submitPersistentVolume()
'resource_id' => $this->resource->id, 'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(), 'resource_type' => $this->resource->getMorphClass(),
]); ]);
$this->resource->refresh(); $this->clearForm();
$this->activeTab = 'volumes';
$this->refreshStorages();
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully'); $this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume'); $this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages'); $this->dispatch('refreshStorages');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -165,11 +252,13 @@ public function submitFileStorage()
'resource_type' => get_class($this->resource), 'resource_type' => get_class($this->resource),
]); ]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully'); $this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file'); $this->dispatch('closeStorageModal', 'file');
$this->clearForm(); $this->dispatch('refreshStorages');
$this->refreshStorages();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -198,11 +287,13 @@ public function submitHostFileStorage()
'resource_type' => get_class($this->resource), 'resource_type' => get_class($this->resource),
]); ]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully'); $this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file'); $this->dispatch('closeStorageModal', 'host-file');
$this->clearForm(); $this->dispatch('refreshStorages');
$this->refreshStorages();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -235,11 +326,13 @@ public function submitFileStorageDirectory()
'resource_type' => get_class($this->resource), 'resource_type' => get_class($this->resource),
]); ]);
$this->clearForm();
$this->activeTab = 'directories';
$this->refreshStorages();
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully'); $this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory'); $this->dispatch('closeStorageModal', 'directory');
$this->clearForm(); $this->dispatch('refreshStorages');
$this->refreshStorages();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -43,11 +43,6 @@ public function render(): View
return view('livewire.project.shared.configuration-checker'); return view('livewire.project.shared.configuration-checker');
} }
public function refreshConfigurationChanges(): void
{
$this->configurationChanged();
}
/** /**
* Members must never see environment variable values, so redact every * Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser. * environment-section change before it is serialized to the browser.
@ -80,18 +75,42 @@ private function redactEnvironmentChanges(array $changes, bool $redact): array
} }
public function configurationChanged(): void public function configurationChanged(): void
{
// Banner only needs a lightweight summary in the Livewire snapshot.
$this->loadConfigurationState(includeChanges: false);
}
public function refreshConfigurationChanges(): void
{
// Full change list is only needed when the user opens "View changes".
$this->loadConfigurationState(includeChanges: true);
}
/**
* @param bool $includeChanges When false, only summary keys are stored (smaller HTML/snapshots).
*/
private function loadConfigurationState(bool $includeChanges = false): void
{ {
$this->resource->refresh(); $this->resource->refresh();
if ($this->resource instanceof Application) { if ($this->resource instanceof Application) {
$diff = $this->resource->pendingDeploymentConfigurationDiff(); $diff = $this->resource->pendingDeploymentConfigurationDiff();
// Fail closed: only owners/admins may see unlocked env values. $this->isConfigurationChanged = $diff->isChanged();
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array = $diff->toArray(); $array = $diff->toArray();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->isConfigurationChanged = $diff->isChanged(); if (! $includeChanges) {
$this->configurationDiff = [
'count' => data_get($array, 'count', 0),
'requires_build' => (bool) data_get($array, 'requires_build', false),
];
return;
}
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->configurationDiff = $array; $this->configurationDiff = $array;
return; return;

View file

@ -32,6 +32,14 @@ class All extends Component
public string $environmentFilter = 'all'; public string $environmentFilter = 'all';
/** @var list<string> */
public array $variableFilters = [];
/** @var list<string> */
public array $serviceFilters = [];
public string $tableSort = 'default';
public int $page = 1; public int $page = 1;
public int $perPage = 10; public int $perPage = 10;
@ -79,7 +87,8 @@ public function mount()
$this->resourceClass = get_class($this->resource); $this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [Application::class]; $resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); $simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $hasGitRepository = filled(data_get($this->resource, 'git_repository'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && $hasGitRepository && ! $simpleDockerfile) {
$this->showPreview = true; $this->showPreview = true;
} }
// Intentionally skip loading env vars / developer-view bulk text here. // Intentionally skip loading env vars / developer-view bulk text here.
@ -357,6 +366,78 @@ public function setEnvironmentFilter(string $filter): void
$this->clearEnvironmentVariableCaches(); $this->clearEnvironmentVariableCaches();
} }
public function toggleVariableFilter(string $filter): void
{
if (! in_array($filter, ['all', 'managed', 'user', 'buildtime', 'runtime', 'multiline', 'literal'], true)) {
return;
}
if ($filter === 'all') {
$this->variableFilters = [];
} elseif (in_array($filter, $this->variableFilters, true)) {
$this->variableFilters = array_values(array_diff($this->variableFilters, [$filter]));
} else {
if ($filter === 'managed') {
$this->variableFilters = array_values(array_diff($this->variableFilters, ['user']));
} elseif ($filter === 'user') {
$this->variableFilters = array_values(array_diff($this->variableFilters, ['managed']));
}
$this->variableFilters[] = $filter;
}
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function setTableSort(string $sort): void
{
if (! in_array($sort, ['default', 'name_asc', 'name_desc'], true)) {
return;
}
$this->tableSort = $sort;
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function toggleServiceFilter(string $service): void
{
if (! in_array($service, $this->serviceFilterOptions, true)) {
return;
}
$this->serviceFilters = in_array($service, $this->serviceFilters, true)
? array_values(array_diff($this->serviceFilters, [$service]))
: [...$this->serviceFilters, $service];
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function clearFilters(): void
{
$this->variableFilters = [];
$this->serviceFilters = [];
$this->environmentFilter = 'all';
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function getServiceFilterOptionsProperty(): array
{
$compose = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
if (blank($compose)) {
return [];
}
return extractHardcodedEnvironmentVariables($compose)
->pluck('service_name')
->filter()
->unique()
->sort()
->values()
->all();
}
public function setEnvironmentVariablePage(int $page): void public function setEnvironmentVariablePage(int $page): void
{ {
$this->page = max(1, min($page, $this->environmentVariableLastPage)); $this->page = max(1, min($page, $this->environmentVariableLastPage));
@ -388,35 +469,35 @@ private function environmentVariableSegments(): array
$segments = []; $segments = [];
if ($includeProduction) { if ($includeProduction) {
$segments[] = [ if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [ $segments[] = [
'kind' => 'hardcoded', 'kind' => 'hardcoded',
'is_preview' => false, 'is_preview' => false,
'count' => $this->hardcodedEnvironmentVariables->count(), 'count' => $this->hardcodedEnvironmentVariables->count(),
]; ];
} }
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
} }
if ($includePreview) { if ($includePreview) {
$segments[] = [ if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [ $segments[] = [
'kind' => 'hardcoded', 'kind' => 'hardcoded',
'is_preview' => true, 'is_preview' => true,
'count' => $this->hardcodedEnvironmentVariablesPreview->count(), 'count' => $this->hardcodedEnvironmentVariablesPreview->count(),
]; ];
} }
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
} }
return $segments; return $segments;
@ -429,6 +510,12 @@ private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
->where('resourceable_id', $this->resource->id) ->where('resourceable_id', $this->resource->id)
->where('is_preview', $isPreview); ->where('is_preview', $isPreview);
if ($this->serviceFilters !== []) {
$query->whereRaw('1 = 0');
}
$query->orderByRaw("CASE WHEN key LIKE 'SERVICE_FQDN%' OR key LIKE 'SERVICE_URL%' OR key LIKE 'SERVICE_NAME%' THEN 0 ELSE 1 END");
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); $query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->searchTerm() !== '') { if ($this->searchTerm() !== '') {
@ -436,7 +523,26 @@ private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']); $query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
} }
if ($this->is_env_sorting_enabled) { if (in_array('managed', $this->variableFilters, true) || in_array('user', $this->variableFilters, true)) {
$method = in_array('managed', $this->variableFilters, true) ? 'where' : 'whereNot';
$query->{$method}(function (Builder $query): void {
$query->where('key', 'like', 'SERVICE_FQDN%')
->orWhere('key', 'like', 'SERVICE_URL%')
->orWhere('key', 'like', 'SERVICE_NAME%');
});
}
foreach (['buildtime', 'runtime', 'multiline', 'literal'] as $filter) {
if (in_array($filter, $this->variableFilters, true)) {
$query->where('is_'.$filter, true);
}
}
if ($this->tableSort === 'name_asc') {
$query->orderBy('key');
} elseif ($this->tableSort === 'name_desc') {
$query->orderByDesc('key');
} elseif ($this->is_env_sorting_enabled) {
$query->orderBy('key'); $query->orderBy('key');
} else { } else {
$query->orderBy('order')->orderBy('id'); $query->orderBy('order')->orderBy('id');
@ -553,6 +659,12 @@ private function showsHardcodedEnvironmentVariables(): bool
return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose'; return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose';
} }
private function includesHardcodedVariables(): bool
{
return ! in_array('user', $this->variableFilters, true)
&& collect($this->variableFilters)->intersect(['buildtime', 'runtime', 'multiline', 'literal'])->isEmpty();
}
protected function getHardcodedVariables(bool $isPreview) protected function getHardcodedVariables(bool $isPreview)
{ {
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) { if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
@ -600,6 +712,12 @@ protected function getHardcodedVariables(bool $isPreview)
}); });
} }
if ($this->serviceFilters !== []) {
$hardcodedVars = $hardcodedVars->filter(
fn ($var) => in_array($var['service_name'] ?? '', $this->serviceFilters, true)
);
}
// Apply sorting based on is_env_sorting_enabled // Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) { if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values(); $hardcodedVars = $hardcodedVars->sortBy('key')->values();

View file

@ -19,6 +19,8 @@
class Show extends Component class Show extends Component
{ {
public bool $showEnvironmentType = true;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
public $parameters; public $parameters;

View file

@ -6,6 +6,8 @@
class ShowHardcoded extends Component class ShowHardcoded extends Component
{ {
public bool $showEnvironmentType = true;
public array $env; public array $env;
public string $key; public string $key;

View file

@ -2,22 +2,284 @@
namespace App\Livewire\Project\Shared\Storages; namespace App\Livewire\Project\Shared\Storages;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ScheduledVolumeBackup;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class All extends Component class All extends Component
{ {
use AuthorizesRequests;
public $resource; public $resource;
protected $listeners = ['refreshStorages' => '$refresh']; /**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool}>
*/
public array $forms = [];
public function getFirstStorageIdProperty() /**
* Precomputed per-volume backup badge/link data.
*
* @var array<int, array{enabled: bool, url: ?string}>
*/
public array $volumeBackupMeta = [];
public bool $supportsPreviewSuffix = false;
public bool $showActionsColumn = false;
public bool $isComposeOrService = false;
public bool $canUpdate = false;
/** Storage id for the single shared backup modal (null = closed / unmounted). */
public ?int $backupModalStorageId = null;
protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
public function mount(): void
{ {
if ($this->resource->persistentStorages->isEmpty()) { $this->canUpdate = (bool) auth()->user()?->can('update', $this->resource);
return null; $this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based();
$this->showActionsColumn = $this->resource instanceof Application;
$this->isComposeOrService = $this->resource->type() === 'service'
|| data_get($this->resource, 'build_pack') === 'dockercompose';
$this->refreshList();
} }
// Use the storage with the smallest ID as the "first" one public function refreshList(): void
// This ensures stability even when storages are deleted {
return $this->resource->persistentStorages->sortBy('id')->first()->id; $this->resource->refresh();
$this->resource->unsetRelation('persistentStorages');
$this->resource->load(['persistentStorages' => fn ($query) => $query->orderBy('id')]);
foreach ($this->resource->persistentStorages as $storage) {
$storage->setRelation('resource', $this->resource);
}
if ($this->resource instanceof Application) {
$this->resource->loadMissing('environment.project');
}
$this->rebuildForms();
$this->rebuildVolumeBackupMeta();
}
public function submit(int $storageId): void
{
$this->authorize('update', $this->resource);
$this->validateStorage($storageId);
$storage = $this->findStorageOrFail($storageId);
if ($storage->shouldBeReadOnlyInUI()) {
$this->dispatch('error', 'This volume is read-only.');
return;
}
$form = $this->forms[$storageId];
$storage->name = $form['name'];
$storage->mount_path = $form['mountPath'];
$storage->host_path = $form['hostPath'] ?: null;
$storage->is_preview_suffix_enabled = (bool) $form['isPreviewSuffixEnabled'];
$storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
public function instantSave(int $storageId): void
{
$this->submit($storageId);
}
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
public function updatedForms($value, string $key): void
{
if (! str_ends_with($key, '.isPreviewSuffixEnabled')) {
return;
}
$storageId = (int) explode('.', $key)[0];
if ($storageId > 0 && isset($this->forms[$storageId]) && ! $this->forms[$storageId]['isReadOnly']) {
$this->instantSave($storageId);
}
}
public function delete(int $storageId, $password = '', $selectedActions = [])
{
$this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$storage = $this->findStorageOrFail($storageId);
if ($storage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
return false;
}
$storage->delete();
$this->backupModalStorageId = null;
$this->refreshList();
$this->dispatch('refreshStorages');
$this->dispatch('configurationChanged');
return true;
}
public function openBackupModal(int $storageId): void
{
$this->authorize('update', $this->resource);
$this->backupModalStorageId = $storageId;
}
public function closeBackupModal(): void
{
$this->backupModalStorageId = null;
}
public function render()
{
return view('livewire.project.shared.storages.all');
}
/**
* @return array<int, LocalPersistentVolume>
*/
public function getStoragesProperty(): array
{
return $this->resource->persistentStorages
->sortBy('id')
->values()
->all();
}
private function rebuildForms(): void
{
$forms = [];
foreach ($this->resource->persistentStorages->sortBy('id') as $storage) {
$forms[$storage->id] = [
'name' => $storage->name,
'mountPath' => $storage->mount_path,
'hostPath' => $storage->host_path,
'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true),
'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate,
];
}
$this->forms = $forms;
}
private function rebuildVolumeBackupMeta(): void
{
$this->volumeBackupMeta = [];
if (! $this->resource instanceof Application) {
return;
}
$storages = $this->resource->persistentStorages;
if ($storages->isEmpty()) {
return;
}
$volumeMorph = (new LocalPersistentVolume)->getMorphClass();
$directoryMorph = (new LocalFileVolume)->getMorphClass();
$volumeIds = $storages->pluck('id');
$volumeBackups = ScheduledVolumeBackup::query()
->where('backupable_type', $volumeMorph)
->whereIn('backupable_id', $volumeIds)
->get()
->keyBy('backupable_id');
$directoryIds = LocalFileVolume::query()
->where('resource_id', $this->resource->id)
->where('resource_type', $this->resource->getMorphClass())
->where('is_directory', true)
->where('is_host_file', false)
->pluck('id');
$totalApplicationBackups = ScheduledVolumeBackup::query()
->where(function ($query) use ($volumeMorph, $volumeIds, $directoryMorph, $directoryIds): void {
$query->where(function ($query) use ($volumeMorph, $volumeIds): void {
$query->where('backupable_type', $volumeMorph)
->whereIn('backupable_id', $volumeIds);
})->orWhere(function ($query) use ($directoryMorph, $directoryIds): void {
$query->where('backupable_type', $directoryMorph)
->whereIn('backupable_id', $directoryIds);
});
})
->count();
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
foreach ($storages as $storage) {
$backup = $volumeBackups->get($storage->id);
$enabled = (bool) ($backup?->enabled);
$url = null;
if ($enabled && $backup) {
$url = $totalApplicationBackups > 1
? route('project.application.backup.index', [...$parameters, 'search' => $storage->name])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
$this->volumeBackupMeta[(int) $storage->id] = [
'enabled' => $enabled,
'url' => $url,
];
}
}
private function validateStorage(int $storageId): void
{
$this->validate([
"forms.{$storageId}.name" => ValidationPatterns::volumeNameRules(),
"forms.{$storageId}.mountPath" => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.hostPath" => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.isPreviewSuffixEnabled" => 'required|boolean',
], array_merge(
ValidationPatterns::volumeNameMessages(),
[
"forms.{$storageId}.mountPath.regex" => 'Mount path must start with / and only contain safe path characters.',
"forms.{$storageId}.hostPath.regex" => 'Host path must start with / and only contain safe path characters.',
]
), [
"forms.{$storageId}.name" => 'name',
"forms.{$storageId}.mountPath" => 'mount',
"forms.{$storageId}.hostPath" => 'host',
]);
}
private function findStorageOrFail(int $storageId): LocalPersistentVolume
{
$storage = $this->resource->persistentStorages->firstWhere('id', $storageId);
if (! $storage) {
$storage = LocalPersistentVolume::query()
->whereKey($storageId)
->where('resource_id', $this->resource->id)
->where('resource_type', $this->resource->getMorphClass())
->firstOrFail();
$storage->setRelation('resource', $this->resource);
}
return $storage;
} }
} }

View file

@ -26,6 +26,8 @@ class Show extends Component
public ?string $startedAt = null; public ?string $startedAt = null;
public bool $supportsPreviewSuffix = false;
// Explicit properties // Explicit properties
public string $name; public string $name;
@ -39,6 +41,14 @@ class Show extends Component
public ?string $backupUrl = null; public ?string $backupUrl = null;
/**
* When true, parent already batched badge/url data skip per-row queries on mount.
*/
public bool $backupMetaHydrated = false;
/** When true, the Backup Configure Livewire modal is mounted (lazy). */
public bool $showBackupModal = false;
protected $validationAttributes = [ protected $validationAttributes = [
'name' => 'name', 'name' => 'name',
'mountPath' => 'mount', 'mountPath' => 'mount',
@ -92,8 +102,15 @@ public function mount(): void
{ {
$this->syncData(false); $this->syncData(false);
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
// PR deployment volume suffixes only apply to git-based applications.
$this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based()
&& ! $this->isService;
// Parent All batches badge/url; isolated embeds still hydrate themselves.
if (! $this->backupMetaHydrated) {
$this->refreshBackupStatus(); $this->refreshBackupStatus();
} }
}
#[On('refreshVolumeBackups')] #[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void public function refreshBackupStatus(): void
@ -107,6 +124,8 @@ public function refreshBackupStatus(): void
return; return;
} }
$this->resource->loadMissing('environment.project');
$parameters = [ $parameters = [
'project_uuid' => $this->resource->project()->uuid, 'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid, 'environment_uuid' => $this->resource->environment->uuid,
@ -122,6 +141,21 @@ public function refreshBackupStatus(): void
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
} }
public function openBackupModal(): void
{
$this->authorize('update', $this->resource);
$this->showBackupModal = true;
}
#[On('modalClosed')]
public function onModalClosed(): void
{
// Drop the nested Create component from the DOM after close to free snapshot weight.
if ($this->showBackupModal) {
$this->showBackupModal = false;
}
}
public function instantSave(): void public function instantSave(): void
{ {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);

View file

@ -113,7 +113,11 @@ public static function relativeName(?string $hostname): string
} }
/** /**
* Plain-text block suitable for clipboard (type / name / value). * BIND-compatible zone snippet for clipboard (absolute names with trailing dots).
*
* Example:
* asd.hu. IN A 172.16.0.2
* www.asd.hu. IN A 172.16.0.2
* *
* @param array<int, array{type: string, name: string, value: string}> $records * @param array<int, array{type: string, name: string, value: string}> $records
*/ */
@ -123,11 +127,38 @@ public static function toCopyText(array $records): string
return ''; return '';
} }
$lines = ["Type\tName\tValue"]; $lines = [];
$nameWidth = 0;
foreach ($records as $record) { foreach ($records as $record) {
$lines[] = "{$record['type']}\t{$record['name']}\t{$record['value']}"; $name = self::bindAbsoluteName((string) $record['name']);
$nameWidth = max($nameWidth, strlen($name));
} }
return implode("\n", $lines); foreach ($records as $record) {
$name = self::bindAbsoluteName((string) $record['name']);
$type = strtoupper((string) $record['type']);
$value = (string) $record['value'];
// AAAA values may be IPv6; leave as-is (no quotes needed for A/AAAA).
$format = '%-'.$nameWidth.'s IN %-5s %s';
$lines[] = sprintf($format, $name, $type, $value);
}
return implode("\n", $lines)."\n";
}
/**
* Absolute BIND name (trailing dot). Leaves @ as-is.
*/
public static function bindAbsoluteName(string $name): string
{
$name = trim($name);
if ($name === '' || $name === '@') {
return '@';
}
$name = rtrim($name, '.');
return $name.'.';
} }
} }

View file

@ -2029,7 +2029,8 @@ function dnsGuidanceTargetAddress(?string $ipOrLabel): ?string
/** /**
* User-facing guidance when a hostname does not resolve to the server. * User-facing guidance when a hostname does not resolve to the server.
* Format: "A record → 1.2.3.4" or "AAAA record → 2001:db8::1". * Format: "Required DNS record type A pointing to 1.2.3.4"
* or "Required DNS record type AAAA pointing to 2001:db8::1".
* *
* @param ?string $targetLabel Display target (IP, or "IP (hostname)") used as fallback. * @param ?string $targetLabel Display target (IP, or "IP (hostname)") used as fallback.
* @param ?string $ipForRecordType Preferred IP for type + display (defaults to $targetLabel). * @param ?string $ipForRecordType Preferred IP for type + display (defaults to $targetLabel).
@ -2045,7 +2046,7 @@ function dnsMismatchGuidanceMessage(?string $targetLabel, ?string $ipForRecordTy
$recordType = dnsRecordTypeForIp($address); $recordType = dnsRecordTypeForIp($address);
return "{$recordType} record → {$address}"; return "Required DNS record type {$recordType} pointing to {$address}";
} }
function validateDNSEntry(string $fqdn, Server $server) function validateDNSEntry(string $fqdn, Server $server)

View file

@ -42,8 +42,6 @@
'host' => env('PUSHER_HOST'), 'host' => env('PUSHER_HOST'),
'port' => env('PUSHER_PORT'), 'port' => env('PUSHER_PORT'),
'app_key' => env('PUSHER_APP_KEY'), 'app_key' => env('PUSHER_APP_KEY'),
'scheme' => env('PUSHER_SCHEME', 'http'),
'force_ws' => filter_var(env('PUSHER_FORCE_WS', false), FILTER_VALIDATE_BOOLEAN),
], ],
'migration' => [ 'migration' => [

View file

@ -21,7 +21,6 @@ services:
PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}" PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_FORCE_WS: "${PUSHER_FORCE_WS:-false}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"

View file

@ -29,7 +29,6 @@ services:
PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}" PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_FORCE_WS: "${PUSHER_FORCE_WS:-false}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"

View file

@ -75,40 +75,13 @@ @layer components {
@apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25; @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
} }
/* Accent rail on the active rounded pill (sits flush on the left edge) */ /* Active state is a solid fill only (no accent rail / border). */
.menu-item-active::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: var(--color-accent);
border-radius: 0.375rem 0 0 0.375rem;
pointer-events: none;
}
.menu-subitem-active::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 3px;
border-radius: 0.375rem 0 0 0.375rem;
background: var(--color-accent);
pointer-events: none;
}
.sidebar-collapsed .menu-item-active::before {
display: none;
}
/* active icon picks up full-strength foreground */
.menu-item-active .menu-item-icon, .menu-item-active .menu-item-icon,
.menu-subitem-active .menu-item-icon { .menu-subitem-active .menu-item-icon {
opacity: 1; opacity: 1;
} }
/* Kill any legacy accent wash; fill is solid via menu-item-active utility. */ /* Kill any legacy accent wash or rail; fill is solid via menu-item-active utility. */
.menu-item-active, .menu-item-active,
.menu-subitem-active, .menu-subitem-active,
.dark .menu-item-active, .dark .menu-item-active,
@ -116,6 +89,12 @@ @layer components {
background-image: none; background-image: none;
} }
.menu-item-active::before,
.menu-subitem-active::before {
content: none;
display: none;
}
/* vertical connector line for a nested nav group */ /* vertical connector line for a nested nav group */
.nav-children { .nav-children {
position: relative; position: relative;
@ -971,6 +950,40 @@ .application-settings-section {
scroll-margin-top: 7rem; scroll-margin-top: 7rem;
} }
/* Brief accent ring when a settings nav sub-item scrolls a section into view.
Use a real border on ::after (not animated multi-layer box-shadow) so the
ring is the same weight on every side box-shadow rings look thicker on
the header edge next to the elevated strip. */
@keyframes application-settings-section-highlight {
0% {
opacity: 0;
}
15%,
60% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.application-settings-section.is-section-highlight {
position: relative;
}
.application-settings-section.is-section-highlight::after {
content: '';
position: absolute;
inset: 0;
z-index: 5;
border-radius: inherit;
border: 0.5px solid var(--color-accent);
pointer-events: none;
animation: application-settings-section-highlight 500ms ease-out forwards;
}
/* Modals reuse the layer-card shell but size to content on large screens */ /* Modals reuse the layer-card shell but size to content on large screens */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.application-settings-section.application-settings-form { .application-settings-section.application-settings-form {
@ -1038,6 +1051,9 @@ @media (min-width: 1280px) {
top: 6.5rem; top: 6.5rem;
align-self: start; align-self: start;
max-height: calc(100dvh - 7.25rem); max-height: calc(100dvh - 7.25rem);
/* Inset content so the default ring-2 + ring-offset-2 focus ring is not
clipped by overflow-x on the right edge of this narrow column. */
padding-right: 0.375rem;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
@ -1111,9 +1127,11 @@ .application-settings-form textarea.input {
.application-settings-workspace .button, .application-settings-workspace .button,
.application-settings-form .button { .application-settings-form .button {
height: 2rem; height: 2rem;
min-height: 2rem;
border-radius: 8px; border-radius: 8px;
padding-left: 0.75rem; padding-left: 0.75rem;
padding-right: 0.75rem; padding-right: 0.75rem;
white-space: nowrap;
} }
.application-settings-workspace .form-control, .application-settings-workspace .form-control,
@ -1292,6 +1310,38 @@ .dark .application-heading-actions .relative > button[x-ref='trigger']:hover {
color: var(--color-fg); color: var(--color-fg);
} }
/*
* Active primary tab styles.
* The base rules above set background/color/box-shadow with higher specificity
* than Tailwind utilities (bg-warning/15, text-warning, ring-*), so active
* tabs need an explicit override or they look identical to inactive ones.
*/
.application-heading-actions .app-tab[aria-current='page'],
.application-heading-actions .app-tab.app-tab-active {
background: color-mix(in srgb, var(--color-coollabs) 10%, transparent);
color: var(--color-coollabs);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-coollabs) 25%, transparent);
}
.application-heading-actions .app-tab[aria-current='page']:hover,
.application-heading-actions .app-tab.app-tab-active:hover {
background: color-mix(in srgb, var(--color-coollabs) 15%, transparent);
color: var(--color-coollabs);
}
.dark .application-heading-actions .app-tab[aria-current='page'],
.dark .application-heading-actions .app-tab.app-tab-active {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 25%, transparent);
}
.dark .application-heading-actions .app-tab[aria-current='page']:hover,
.dark .application-heading-actions .app-tab.app-tab-active:hover {
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
color: var(--color-warning);
}
.application-heading-actions .relative > button[x-ref='trigger'] { .application-heading-actions .relative > button[x-ref='trigger'] {
padding-right: 0.625rem; padding-right: 0.625rem;
} }
@ -1583,7 +1633,11 @@ .dark .data-table-row:hover {
} }
.env-table-grid { .env-table-grid {
grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4rem 4.5rem 4.8rem 4.2rem 3rem; grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
}
.env-table-grid.env-table-grid-no-type {
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
} }
/* Shared variables only store value shape (multiline), not per-resource flags. */ /* Shared variables only store value shape (multiline), not per-resource flags. */
@ -1594,7 +1648,7 @@ .env-table-grid-shared {
/* Env vars: collapse flag columns on tablet, card layout on phone */ /* Env vars: collapse flag columns on tablet, card layout on phone */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.env-table-grid { .env-table-grid {
grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 3rem; grid-template-columns: minmax(0, 1.4fr) 4.8rem 6rem 3rem;
gap: 0.75rem; gap: 0.75rem;
} }
@ -1619,12 +1673,7 @@ @media (max-width: 1100px) {
@media (max-width: 900px) { @media (max-width: 900px) {
.env-table-grid { .env-table-grid {
grid-template-columns: minmax(0, 1fr) 6rem 3rem; grid-template-columns: minmax(0, 1fr) 4.8rem 6rem 3rem;
}
/* Also hide Comment (3) */
.env-table-grid > :nth-child(3) {
display: none;
} }
.env-table-grid-shared { .env-table-grid-shared {
@ -1670,14 +1719,14 @@ @media (max-width: 640px) {
word-break: break-word; word-break: break-word;
} }
/* Type desktop column → hide; type shows as mobile badge on name row */ /* Managed and Type desktop columns */
.data-table-row.env-table-grid > :nth-child(2), .data-table-row.env-table-grid > :nth-child(2),
.data-table-row.env-table-grid > :nth-child(3),
.data-table-row.env-table-grid-shared > :nth-child(2) { .data-table-row.env-table-grid-shared > :nth-child(2) {
display: none !important; display: none !important;
} }
/* Comment / flags already hidden; keep meta area for optional second line */ /* Comment / flags already hidden; keep meta area for optional second line */
.data-table-row.env-table-grid > :nth-child(3),
.data-table-row.env-table-grid > :nth-child(4), .data-table-row.env-table-grid > :nth-child(4),
.data-table-row.env-table-grid > :nth-child(5), .data-table-row.env-table-grid > :nth-child(5),
.data-table-row.env-table-grid > :nth-child(6), .data-table-row.env-table-grid > :nth-child(6),
@ -1695,13 +1744,10 @@ @media (max-width: 640px) {
justify-self: end; justify-self: end;
} }
.env-type-mobile {
display: inline-flex !important;
}
} }
.env-type-mobile { .env-managed-desktop {
display: none; display: flex;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@ -1835,6 +1881,154 @@ .backup-table-grid {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem; grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem;
} }
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | [Actions] */
.volumes-table-grid-readonly {
grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr);
}
.volumes-table-grid {
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(10.5rem, auto);
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem minmax(10.5rem, auto);
}
.volumes-mobile-label {
display: none;
}
/*
* Same tokens as .application-settings-form label (13px / medium / subtle).
* Do not use text-sm (14px) settings labels override Tailwind to 13px.
*/
.volumes-mobile-label.is-visible,
.volumes-field-label {
font-size: 13px;
font-weight: 500;
line-height: 1rem;
color: var(--coollabs-subtle);
}
/* Compact inputs inside volume table rows (desktop) */
.data-table-row.volumes-table-grid .input,
.data-table-row.volumes-table-grid-with-pr .input,
.data-table-row.volumes-table-grid .listbox-trigger,
.data-table-row.volumes-table-grid-with-pr .listbox-trigger {
min-height: 2rem;
height: 2rem;
font-size: 12px;
}
.data-table-row.volumes-table-grid .listbox-trigger,
.data-table-row.volumes-table-grid-with-pr .listbox-trigger {
padding-inline: 0.5rem;
}
@media (max-width: 1100px) {
.volumes-table-grid {
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) minmax(9rem, auto);
}
.volumes-table-grid > .volumes-col-source,
.data-table-header.volumes-table-grid > .volumes-col-source {
display: none;
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem minmax(9rem, auto);
}
.volumes-table-grid-with-pr > .volumes-col-source,
.data-table-header.volumes-table-grid-with-pr > .volumes-col-source {
display: none;
}
.volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr);
}
.volumes-table-grid-readonly > .volumes-col-source,
.data-table-header.volumes-table-grid-readonly > .volumes-col-source {
display: none;
}
}
/* Phone: stacked card rows with per-field labels (table headers hidden) */
@media (max-width: 768px) {
.data-table-header.volumes-table-grid,
.data-table-header.volumes-table-grid-with-pr,
.data-table-header.volumes-table-grid-readonly {
display: none;
}
.data-table-row.volumes-table-grid,
.data-table-row.volumes-table-grid-with-pr,
.data-table-row.volumes-table-grid-readonly {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
padding: 0.875rem 1rem;
min-height: 0;
}
.data-table-row.volumes-table-grid > .volumes-col-source,
.data-table-row.volumes-table-grid-with-pr > .volumes-col-source,
.data-table-row.volumes-table-grid-with-pr > .volumes-col-pr,
.data-table-row.volumes-table-grid-readonly > .volumes-col-source {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
width: 100%;
}
.data-table-row.volumes-table-grid > *,
.data-table-row.volumes-table-grid-with-pr > *,
.data-table-row.volumes-table-grid-readonly > * {
width: 100%;
min-width: 0;
}
/* Match .application-settings-form label (13px), not Tailwind text-sm (14px) */
.volumes-mobile-label {
display: block;
font-size: 13px;
font-weight: 500;
line-height: 1rem;
color: var(--coollabs-subtle);
}
.volumes-cell-name,
.volumes-cell-dest,
.volumes-cell-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
width: 100%;
}
.volumes-cell-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0.25rem;
}
.data-table-row.volumes-table-grid .input,
.data-table-row.volumes-table-grid-with-pr .input,
.data-table-row.volumes-table-grid .listbox-trigger,
.data-table-row.volumes-table-grid-with-pr .listbox-trigger {
min-height: 2.25rem;
height: 2.25rem;
font-size: 13px;
}
}
.deployment-table-grid { .deployment-table-grid {
grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr); grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr);
} }
@ -2587,6 +2781,37 @@ .dark .table-badge-danger {
color: #f87171; color: #f87171;
} }
.table-badge-warning {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.dark .table-badge-warning {
background: rgba(245, 158, 11, 0.14);
color: #fbbf24;
}
.table-badge-success {
background: rgba(16, 185, 129, 0.14);
color: #047857;
}
.dark .table-badge-success {
background: rgba(16, 185, 129, 0.16);
color: #34d399;
}
/* Suggested / not-yet-configured domain rows — distinct from real FQDNs */
.domains-row-suggested {
background: rgba(245, 158, 11, 0.05);
box-shadow: inset 3px 0 0 0 rgba(245, 158, 11, 0.55);
}
.dark .domains-row-suggested {
background: rgba(245, 158, 11, 0.07);
box-shadow: inset 3px 0 0 0 rgba(251, 191, 36, 0.5);
}
/* Chip/tag input (comma-free multi-value entry, e.g. Domains) */ /* Chip/tag input (comma-free multi-value entry, e.g. Domains) */
.chip-input { .chip-input {
display: flex; display: flex;

View file

@ -126,7 +126,8 @@ @utility select {
} }
@utility button { @utility button {
@apply inline-flex gap-1.5 justify-center items-center px-2.5 h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; /* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */
@apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
} }
/* Compact icon-only control (gear, chevrons, etc.) */ /* Compact icon-only control (gear, chevrons, etc.) */
@ -144,6 +145,11 @@ @utility app-tab {
@apply inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[13px] font-medium text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg transition-colors; @apply inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[13px] font-medium text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg transition-colors;
} }
/* Active resource tab (used with aria-current="page") */
@utility app-tab-active {
@apply bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20;
}
@utility auth-tooltip { @utility auth-tooltip {
@apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10; @apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
} }
@ -216,7 +222,7 @@ @utility menu-item-label {
} }
@utility menu-item-active { @utility menu-item-active {
/* Solid selected pill + accent rail (app.css ::before). No accent gradient. */ /* Solid selected pill only — no accent rail / border. */
@apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06]; @apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
} }
@ -227,10 +233,11 @@ @utility nav-section {
/* Indented child rows in a collapsible nav group */ /* Indented child rows in a collapsible nav group */
@utility menu-subitem { @utility menu-subitem {
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg; /* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
} }
@utility menu-subitem-active { @utility menu-subitem-active {
@apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06]; @apply rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
} }
@utility sub-menu-wrapper { @utility sub-menu-wrapper {

View file

@ -12,3 +12,110 @@ document.addEventListener('livewire:navigated', () => {
// Keeping this registration independent from the current route also makes it // Keeping this registration independent from the current route also makes it
// available before Alpine processes terminal markup after wire:navigate. // available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent); document.addEventListener('alpine:init', initializeTerminalComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
* after the scroll has settled. Starting the flash immediately makes long
* jumps (top bottom) finish scrolling after the animation has already ended.
*
* @param {string} id
*/
window.scrollToSettingsSection = function scrollToSettingsSection(id) {
const el = document.getElementById(id);
if (!el) {
return;
}
if (typeof el._sectionHighlightCleanup === 'function') {
el._sectionHighlightCleanup();
}
const runHighlight = () => {
el.classList.remove('is-section-highlight');
// Force reflow so the 500ms highlight can re-run on repeated clicks.
void el.offsetWidth;
el.classList.add('is-section-highlight');
el._sectionHighlightTimer = window.setTimeout(() => {
el.classList.remove('is-section-highlight');
}, 500);
};
let finished = false;
let rafId = 0;
let scrollEndHandler = null;
const cleanup = () => {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollEndHandler) {
window.removeEventListener('scrollend', scrollEndHandler);
scrollEndHandler = null;
}
if (el._sectionHighlightTimer) {
window.clearTimeout(el._sectionHighlightTimer);
el._sectionHighlightTimer = null;
}
};
const finish = () => {
if (finished) {
return;
}
finished = true;
cleanup();
runHighlight();
};
el._sectionHighlightCleanup = () => {
finished = true;
cleanup();
el.classList.remove('is-section-highlight');
el._sectionHighlightCleanup = null;
};
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Prefer the native scrollend event when the browser fires it.
scrollEndHandler = () => finish();
window.addEventListener('scrollend', scrollEndHandler, { once: true });
// Fallback: wait until the target's Y position is stable for a few frames
// (covers browsers without scrollend, and no-op scrolls when already in view).
let lastTop = null;
let stableFrames = 0;
let frames = 0;
const maxFrames = 180; // ~3s safety cap
const tick = () => {
if (finished) {
return;
}
frames += 1;
const top = el.getBoundingClientRect().top;
if (lastTop !== null && Math.abs(top - lastTop) < 0.5) {
stableFrames += 1;
} else {
stableFrames = 0;
}
lastTop = top;
// Skip the first couple frames so we don't flash before smooth scroll starts.
if (frames > 4 && stableFrames >= 4) {
finish();
return;
}
if (frames >= maxFrames) {
finish();
return;
}
rafId = window.requestAnimationFrame(tick);
};
rafId = window.requestAnimationFrame(tick);
};

View file

@ -5,6 +5,7 @@
'required' => false, 'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool] 'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…', 'placeholder' => 'Select…',
'emptyText' => 'No options available.',
'live' => false, 'live' => false,
'onChange' => null, // optional $wire method to call after a selection 'onChange' => null, // optional $wire method to call after a selection
'wire' => true, // false = purely client-side value (no Livewire binding) 'wire' => true, // false = purely client-side value (no Livewire binding)
@ -51,7 +52,7 @@
{{ $attributes->whereStartsWith('x-effect') }} {{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false"> @click.outside="open = false" @keydown.escape="open = false">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="open = !open" <button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="open = !open"
@disabled($disabled) aria-haspopup="listbox" @disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" :title="current"> :aria-expanded="open" :title="current">
<span class="listbox-trigger-label" x-text="current"></span> <span class="listbox-trigger-label" x-text="current"></span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
@ -60,6 +61,10 @@
</svg> </svg>
</button> </button>
<div class="listbox-panel" x-show="open" x-cloak role="listbox"> <div class="listbox-panel" x-show="open" x-cloak role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)"> <template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option" <button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }" :class="{ 'listbox-option-disabled': option.disabled }"

View file

@ -43,14 +43,14 @@
{{ $attributes->merge(['class' => 'relative z-10 inline-block align-middle']) }}> {{ $attributes->merge(['class' => 'relative z-10 inline-block align-middle']) }}>
{{-- button (not div) so label-for associations do not steal the click on mobile --}} {{-- button (not div) so label-for associations do not steal the click on mobile --}}
<button type="button" x-ref="trigger" <button type="button" x-ref="trigger"
class="info-helper relative z-10 inline-flex size-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none" class="info-helper relative z-10 inline-flex size-3.5 shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none"
aria-label="More information" @mouseenter="show(false)" @mouseleave="hide" aria-label="More information" @mouseenter="show(false)" @mouseleave="hide"
@click.prevent.stop="open && pinned ? close() : show(true)"> @click.prevent.stop="open && pinned ? close() : show(true)">
@isset($icon) @isset($icon)
{{ $icon }} {{ $icon }}
@else @else
<x-reicon name="info-circle" <x-reicon name="info-circle"
class="size-4 text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim" class="size-3.5 text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim"
aria-hidden="true" /> aria-hidden="true" />
@endisset @endisset
</button> </button>

View file

@ -28,8 +28,9 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
@foreach ($items as $item) @foreach ($items as $item)
<a @class([ <a @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $item['active'], 'app-tab-active' => $item['active'],
]) ])
@if ($item['active']) aria-current="page" @endif
{{ wireNavigate() }} href="{{ route($item['route'], $routeParameters) }}"> {{ wireNavigate() }} href="{{ route($item['route'], $routeParameters) }}">
<x-reicon :name="$item['icon']" class="size-3.5" /> <x-reicon :name="$item['icon']" class="size-3.5" />
{{ $item['label'] }} {{ $item['label'] }}

View file

@ -171,15 +171,7 @@ function checkTheme() {
} }
} }
@auth @auth
@php
$pusherForceWs = (bool) config('constants.pusher.force_ws');
@endphp
window.Pusher = Pusher; window.Pusher = Pusher;
@if ($pusherForceWs)
if (window.Pusher && window.Pusher.Runtime) {
window.Pusher.Runtime.getProtocol = function () { return 'http:'; };
}
@endif
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default; const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
window.Echo = new EchoConstructor({ window.Echo = new EchoConstructor({
broadcaster: 'pusher', broadcaster: 'pusher',
@ -189,11 +181,13 @@ function checkTheme() {
wsPort: "{{ getRealtime() }}", wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}", wssPort: "{{ getRealtime() }}",
forceTLS: false, forceTLS: false,
encrypted: @json($pusherForceWs ? false : true), encrypted: true,
enableStats: false, enableStats: false,
enableLogging: true, enableLogging: true,
enabledTransports: ['ws', 'wss'],
disableStats: true, disableStats: true,
enabledTransports: @json($pusherForceWs ? ['ws'] : ['ws', 'wss']), // Add auto reconnection settings
enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'], disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
// Attempt to reconnect on connection lost // Attempt to reconnect on connection lost
autoReconnect: true, autoReconnect: true,

View file

@ -2,10 +2,11 @@
@php @php
$canUpdate = auth()->user()->can('update', $application); $canUpdate = auth()->user()->can('update', $application);
$labelsManagedByCoolify = $application->settings->is_container_label_readonly_enabled; $labelsManagedByCoolify = $application->settings->is_container_label_readonly_enabled;
// Use model UUIDs: Livewire update requests do not carry page route params.
$generalRouteParameters = [ $generalRouteParameters = [
'project_uuid' => request()->route('project_uuid'), 'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => request()->route('environment_uuid'), 'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => request()->route('application_uuid'), 'application_uuid' => $application->uuid,
]; ];
@endphp @endphp
@ -43,7 +44,7 @@
['value' => false, 'label' => 'Generated name (rolling updates)'], ['value' => false, 'label' => 'Generated name (rolling updates)'],
['value' => true, 'label' => 'Consistent name (no rolling updates)'], ['value' => true, 'label' => 'Consistent name (no rolling updates)'],
]" :disabled="! $canUpdate" /> ]" :disabled="! $canUpdate" />
@if ($isConsistentContainerNameEnabled === false) @if ($isConsistentContainerNameEnabled === true)
<x-forms.input <x-forms.input
helper="You can add a custom name for your container.<br><br>The name is saved automatically and converted to slug format. <span class='font-bold dark:text-warning'>You will lose the rolling update feature!</span>" helper="You can add a custom name for your container.<br><br>The name is saved automatically and converted to slug format. <span class='font-bold dark:text-warning'>You will lose the rolling update feature!</span>"
id="customInternalName" label="Custom container name" canGate="update" id="customInternalName" label="Custom container name" canGate="update"

View file

@ -48,7 +48,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify {{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</x-slot> </x-slot>
<livewire:project.shared.configuration-checker :resource="$application" /> <livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" /> <livewire:project.application.heading :application="$application" wire:key="application-heading-backup-index" />
<div class="application-settings-form flex flex-col gap-6"> <div class="application-settings-form flex flex-col gap-6">
<x-application.settings-section title="Storage backups" <x-application.settings-section title="Storage backups"

View file

@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify {{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</x-slot> </x-slot>
<livewire:project.application.heading :application="$application" /> <livewire:project.application.heading :application="$application" wire:key="application-heading-backup-show" />
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0"> <section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10"> <div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">

View file

@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Configuration | Coolify {{ data_get_str($application, 'name')->limit(10) }} > Configuration | Coolify
</x-slot> </x-slot>
<livewire:project.shared.configuration-checker :resource="$application" /> <livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" /> <livewire:project.application.heading :application="$application" :wire:key="'application-heading-'.$currentRoute" />
@php @php
$applicationRouteParameters = [ $applicationRouteParameters = [
@ -238,11 +238,18 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
@endif @endif
</a> </a>
@if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4) @if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4)
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex" x-data="{ activeSection: '' }"> <div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{
activeSection: '',
scrollToSection(id) {
this.activeSection = id;
window.scrollToSettingsSection?.(id);
},
}">
@foreach ($pageSections[$menuItem['route']] as $section) @foreach ($pageSections[$menuItem['route']] as $section)
<button type="button" class="menu-subitem" <button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'" :class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="activeSection = '{{ $section['id'] }}'; document.getElementById('{{ $section['id'] }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"> @click="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span> <span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button> </button>
@endforeach @endforeach

View file

@ -1,7 +1,7 @@
<div> <div>
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot> <x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" /> <livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" /> <livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-index" />
@php @php
$lastPage = max(1, (int) ceil($deployments_count / $defaultTake)); $lastPage = max(1, (int) ceil($deployments_count / $defaultTake));

View file

@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify {{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify
</x-slot> </x-slot>
<livewire:project.shared.configuration-checker :resource="$application" /> <livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" /> <livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-show" />
<div x-data="{ <div x-data="{
fullscreen: @entangle('fullscreen'), fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},

View file

@ -7,7 +7,38 @@
: 'Manage domains for this application.'; : 'Manage domains for this application.';
@endphp @endphp
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4"
x-data="{
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
// Local-only until Save — never touch $wire on open/close (avoids Livewire toJSON proxy bugs).
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingService: @js($editingService),
openEditDomain(index, url, service) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingService = service;
this.editingServiceLabel = service || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingService = null;
},
prepareEditSubmit() {
// Sync Alpine → Livewire only when the user actually saves (one request).
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingService = this.localEditingService;
$wire.showEditDomainModal = true;
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)">
<x-application.settings-section id="domains-section" title="Domains" :helper="$helperText"> <x-application.settings-section id="domains-section" title="Domains" :helper="$helperText">
@can('update', $application) @can('update', $application)
<x-slot:actions> <x-slot:actions>
@ -19,7 +50,7 @@
@endcan @endcan
@if ($labelsAreWritable) @if ($labelsAreWritable)
<x-callout type="warning" title="Domains managed via labels"> <x-callout type="warning" title="Domains managed via labels" class="mb-4">
Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. Container label readonly mode is disabled. Domains must be set in the Labels section on the General page.
</x-callout> </x-callout>
@endif @endif
@ -84,7 +115,7 @@
<p class="text-[13px] text-neutral-500 dark:text-fg-dim"> <p class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} {{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0) @if ($suggestedCount > 0)
· {{ $suggestedCount }} suggested · {{ $suggestedCount }} not added
@endif @endif
</p> </p>
</div> </div>
@ -116,13 +147,12 @@ class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-co
required /> required />
@if ($addDomainDnsFailed) @if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed"> <x-callout type="danger" title="DNS is not pointing to the right IP">
{{ $addDomainDnsMessage }} This domain does not currently resolve to this server.
@if ($serverIp) Traffic may not reach Coolify until you update DNS.
<div class="pt-2 text-sm"> Are you sure you want to add it anyway?
Expected target: @if (filled($addDomainDnsMessage))
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span> <div class="pt-2">{{ $addDomainDnsMessage }}</div>
</div>
@endif @endif
</x-callout> </x-callout>
@endif @endif
@ -187,7 +217,7 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $hasRows ? 'is-flu
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim"> <p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
{{ $serviceConfigured }} domain{{ $serviceConfigured === 1 ? '' : 's' }} {{ $serviceConfigured }} domain{{ $serviceConfigured === 1 ? '' : 's' }}
@if ($serviceSuggested > 0) @if ($serviceSuggested > 0)
· {{ $serviceSuggested }} suggested · {{ $serviceSuggested }} not added
@endif @endif
</p> </p>
</div> </div>
@ -225,7 +255,7 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $hasRows ? 'is-flu
<div class="data-table-header domains-table-grid-compose"> <div class="data-table-header domains-table-grid-compose">
<span>Domain</span> <span>Domain</span>
<span>Service</span> <span>Service</span>
<span>DNS</span> <span>DNS Check</span>
<span>Last checked</span> <span>Last checked</span>
<span></span> <span></span>
</div> </div>
@ -254,7 +284,7 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $hasRows ? 'is-flu
<div class="data-table w-full"> <div class="data-table w-full">
<div class="data-table-header domains-table-grid"> <div class="data-table-header domains-table-grid">
<span>Domain</span> <span>Domain</span>
<span>DNS</span> <span>DNS Check</span>
<span>Last checked</span> <span>Last checked</span>
<span></span> <span></span>
</div> </div>
@ -271,11 +301,9 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $hasRows ? 'is-flu
@endif @endif
</div> </div>
{{-- Edit domain modal --}} {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
@if ($showEditDomainModal) <div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
<div x-data="{ modalOpen: @entangle('showEditDomainModal') }" @keydown.window.escape="if (modalOpen) { closeEditDomain() }">
@keydown.escape.window="modalOpen = false; $wire.cancelEdit()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body"> <template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak> <div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
@ -283,7 +311,7 @@ class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]" class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.cancelEdit()"></div> @click="closeEditDomain()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center"> <div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100" x-transition:enter="ease-out duration-100"
@ -296,42 +324,56 @@ class="application-settings-form application-settings-section relative flex max-
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!"> <header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3> <h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<button type="button" wire:click="cancelEdit" <button type="button" @click="closeEditDomain()"
class="icon-button shrink-0" aria-label="Close"> class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" /> <x-reicon name="x" class="size-4" />
</button> </button>
</header> </header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto" <div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;"> style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4"> <form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
@if ($editingService) <div x-show="editingServiceLabel" x-cloak class="w-full">
<x-forms.input label="Service" value="{{ $editingService }}" readonly /> <div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
@endif <label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service</label>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<x-forms.input id="editingDomain" label="Domain URL" <div class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4" for="editingDomainLocal">
Domain URL <x-highlighted text="*" />
</label>
</div>
<input id="editingDomainLocal" type="url" class="input" required
placeholder="https://app.example.com" placeholder="https://app.example.com"
helper="Full URL including scheme. Optional path and container port are supported.<br><br><span class='text-helper'>Examples</span><br>- https://app.coolify.io<br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000<br>- https://app.coolify.io:8080/api" x-model="localEditingDomain" />
required /> <p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Full URL including scheme. Optional path and container port are supported.
</p>
@error('editingDomain')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($editDomainDnsFailed) @if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed"> <x-callout type="danger" title="DNS is not pointing to the right IP">
{{ $editDomainDnsMessage }} This domain does not currently resolve to this server.
@if ($serverIp) Traffic may not reach Coolify until you update DNS.
<div class="pt-2 text-sm"> Are you sure you want to save it anyway?
Expected target: @if (filled($editDomainDnsMessage))
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span> <div class="pt-2">{{ $editDomainDnsMessage }}</div>
</div>
@endif @endif
</x-callout> </x-callout>
@endif @endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2"> <div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" wire:click="cancelEdit"> <x-forms.button type="button" @click="closeEditDomain()">
Cancel Cancel
</x-forms.button> </x-forms.button>
@if ($editDomainDnsFailed) @if ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="confirmUpdateDomainDespiteDns" <x-forms.button type="button" isError
isError> @click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
Continue Continue
</x-forms.button> </x-forms.button>
@else @else
@ -347,7 +389,6 @@ class="icon-button shrink-0" aria-label="Close">
</div> </div>
</template> </template>
</div> </div>
@endif
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" <x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" /> confirmAction="confirmDomainUsage" />

View file

@ -501,7 +501,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu
icon-name="admin"> icon-name="admin">
<x-slot:contents> <x-slot:contents>
<button type="button" class="button" <button type="button" class="button"
@click="document.getElementById('container-labels-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"> @click="window.scrollToSettingsSection?.('container-labels-section')">
Go to Container labels Go to Container labels
</button> </button>
</x-slot:contents> </x-slot:contents>

View file

@ -1,11 +1,21 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-[1180px] pb-4 md:pb-6 lg:pb-0"> <nav wire:poll.10000ms="checkStatus" class="w-full max-w-[1180px] pb-4 md:pb-6 lg:pb-0">
@php @php
$routeIs = fn (string|array $routes): bool => \Illuminate\Support\Str::is($routes, $activeRouteName); $routeIs = fn (string|array $routes): bool => \Illuminate\Support\Str::is($routes, $activeRouteName);
// Settings covers all configuration sub-pages (General, Webhooks, Domains, …),
// not only project.application.configuration. Primary tabs that are NOT settings:
// backups, console, deployment logs, runtime logs.
$isSettingsRoute = $routeIs('project.application.*')
&& ! $routeIs([
'project.application.backup.*',
'project.application.command',
'project.application.deployment.*',
'project.application.logs',
]);
$applicationMenuItems = [ $applicationMenuItems = [
[ [
'label' => 'Settings', 'label' => 'Settings',
'route' => 'project.application.configuration', 'route' => 'project.application.configuration',
'active' => $routeIs('project.application.configuration'), 'active' => $isSettingsRoute,
], ],
[ [
'label' => 'Backups', 'label' => 'Backups',
@ -31,112 +41,10 @@
], ],
]; ];
$configurationMenuItems = [
[
'label' => 'General',
'route' => 'project.application.configuration',
'active' => $routeIs('project.application.configuration'),
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
'active' => $routeIs('project.application.domains'),
],
[
'label' => 'Advanced',
'route' => 'project.application.advanced',
'active' => $routeIs('project.application.advanced'),
],
[
'label' => 'Swarm',
'route' => 'project.application.swarm',
'active' => $routeIs('project.application.swarm'),
'visible' => $application->destination->server->isSwarm(),
],
[
'label' => 'Environment Variables',
'route' => 'project.application.environment-variables',
'active' => $routeIs('project.application.environment-variables'),
],
[
'label' => 'Persistent Storage',
'route' => 'project.application.persistent-storage',
'active' => $routeIs('project.application.persistent-storage'),
],
[
'label' => 'Git Source',
'route' => 'project.application.source',
'active' => $routeIs('project.application.source'),
'visible' => $application->git_based(),
],
[
'label' => 'Servers',
'route' => 'project.application.servers',
'active' => $routeIs('project.application.servers'),
],
[
'label' => 'Scheduled Tasks',
'route' => 'project.application.scheduled-tasks.show',
'active' => $routeIs(['project.application.scheduled-tasks.show', 'project.application.scheduled-tasks']),
],
[
'label' => 'Webhooks',
'route' => 'project.application.webhooks',
'active' => $routeIs('project.application.webhooks'),
],
[
'label' => 'Preview Deployments',
'route' => 'project.application.preview-deployments',
'active' => $routeIs('project.application.preview-deployments'),
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
],
[
'label' => 'Healthcheck',
'route' => 'project.application.healthcheck',
'active' => $routeIs('project.application.healthcheck'),
'visible' => $application->build_pack !== 'dockercompose',
],
[
'label' => 'Rollback',
'route' => 'project.application.rollback',
'active' => $routeIs('project.application.rollback'),
],
[
'label' => 'Resource Limits',
'route' => 'project.application.resource-limits',
'active' => $routeIs('project.application.resource-limits'),
],
[
'label' => 'Resource Operations',
'route' => 'project.application.resource-operations',
'active' => $routeIs('project.application.resource-operations'),
],
[
'label' => 'Metrics',
'route' => 'project.application.metrics',
'active' => $routeIs('project.application.metrics'),
],
[
'label' => 'Tags',
'route' => 'project.application.tags',
'active' => $routeIs('project.application.tags'),
],
[
'label' => 'Danger Zone',
'route' => 'project.application.danger',
'active' => $routeIs('project.application.danger'),
],
];
$applicationMenuItems = array_values(array_filter( $applicationMenuItems = array_values(array_filter(
$applicationMenuItems, $applicationMenuItems,
fn (array $item): bool => $item['visible'] ?? true, fn (array $item): bool => $item['visible'] ?? true,
)); ));
$configurationMenuItems = array_values(array_filter(
$configurationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$activeConfigurationMenuItem = collect($configurationMenuItems)->firstWhere('active', true);
$applicationStatus = str($application->status ?? 'exited'); $applicationStatus = str($application->status ?? 'exited');
[$applicationStatusLabel, $applicationStatusType] = match (true) { [$applicationStatusLabel, $applicationStatusType] = match (true) {
$applicationStatus->startsWith('running') => ['Running', 'success'], $applicationStatus->startsWith('running') => ['Running', 'success'],
@ -276,14 +184,11 @@ class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-20
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}} {{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0 flex-1"> <x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($applicationMenuItems as $menuItem) @foreach ($applicationMenuItems as $menuItem)
@php
$isMobileApplicationItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a @class([ <a @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $isMobileApplicationItemActive, 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}"> href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}
@ -326,15 +231,12 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
{{-- Tabs alone may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}} {{-- Tabs alone may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0"> <x-resource-heading-tabs class="min-w-0">
@foreach ($applicationMenuItems as $menuItem) @foreach ($applicationMenuItems as $menuItem)
@php
$isApplicationMenuItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a wire:key="application-primary-nav-{{ str($menuItem['label'])->slug() }}" <a wire:key="application-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([ @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $isApplicationMenuItemActive, 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}"> href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}

View file

@ -24,24 +24,32 @@ class="env-table-item">
<div @class([ <div @class([
'data-table-row', 'data-table-row',
$gridClass, $gridClass,
'opacity-90' => $isSuggested, 'domains-row-suggested' => $isSuggested,
])> ])>
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">
@if ($isSuggested)
<span
class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
title="{{ $row['url'] }} (not configured yet)">
{{ $row['url'] }}
</span>
@else
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank" <a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 font-mono text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning" class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}"> title="{{ $row['url'] }}">
{{ $row['url'] }} {{ $row['url'] }}
</a> </a>
@endif
@if ($isSuggested && ! empty($row['suggestion_label'])) @if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge shrink-0">{{ $row['suggestion_label'] }}</span> <span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif @endif
@if ($isCompose ?? false) @if ($isCompose ?? false)
<span class="domains-service-mobile table-badge shrink-0">{{ $row['service'] ?? '-' }}</span> <span class="domains-service-mobile table-badge shrink-0">{{ $row['service'] ?? '-' }}</span>
@endif @endif
</div> </div>
@if ($row['dns_status'] !== 'ok' && filled($row['dns_message'])) @if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-neutral-500 sm:truncate dark:text-fg-dim" <p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
title="{{ $row['dns_message'] }}"> title="{{ $row['dns_message'] }}">
{{ $row['dns_message'] }} {{ $row['dns_message'] }}
</p> </p>
@ -56,8 +64,13 @@ class="min-w-0 font-mono text-[13px] text-black underline decoration-neutral-300
@endif @endif
<div class="flex min-w-0 items-center"> <div class="flex min-w-0 items-center">
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType" <x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" /> :title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@endif
</div> </div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim" <div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@ -84,12 +97,18 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
Continue Continue
</x-forms.button> </x-forms.button>
@else @else
<x-forms.button wire:click="addSuggestedDomain({{ $index }})" isHighlighted class="h-7! px-2! text-[12px]!"> <x-forms.button wire:click="addSuggestedDomain({{ $index }})" isHighlighted class="h-7! shrink-0 px-2.5! text-[12px]!">
Add Add domain
</x-forms.button> </x-forms.button>
@endif @endif
@else @else
<button type="button" wire:click="startEdit({{ $index }})" class="icon-button shrink-0" <button type="button"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
service: @js($row['service'] ?? null),
})"
class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain"> title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" /> <x-reicon name="settings" class="size-3.5" />
</button> </button>

View file

@ -155,8 +155,9 @@ class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-20
@foreach ($databasePageItems as $menuItem) @foreach ($databasePageItems as $menuItem)
<a @class([ <a @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'], 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}"> href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}
@ -175,8 +176,9 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}" <a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([ @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $menuItem['active'], 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}"> href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}

View file

@ -8,7 +8,36 @@
$singleAppId = $singleApp['id'] ?? null; $singleAppId = $singleApp['id'] ?? null;
@endphp @endphp
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4"
x-data="{
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: '',
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingServiceApplicationId: @js($editingServiceApplicationId),
openEditDomain(index, url, serviceApplicationId, serviceLabel) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingServiceApplicationId = serviceApplicationId;
this.editingServiceLabel = serviceLabel || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingServiceApplicationId = null;
},
prepareEditSubmit() {
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
$wire.showEditDomainModal = true;
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)">
<x-application.settings-section id="service-domains-section" title="Domains" <x-application.settings-section id="service-domains-section" title="Domains"
helper="Manage domains and www/non-www redirects for applications in this stack."> helper="Manage domains and www/non-www redirects for applications in this stack.">
@can('update', $service) @can('update', $service)
@ -63,7 +92,7 @@
<p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim"> <p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} {{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0) @if ($suggestedCount > 0)
· {{ $suggestedCount }} suggested · {{ $suggestedCount }} not added
@endif @endif
</p> </p>
<div class="ml-auto flex flex-wrap items-center gap-2"> <div class="ml-auto flex flex-wrap items-center gap-2">
@ -97,13 +126,12 @@ class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-co
required /> required />
@if ($addDomainDnsFailed) @if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed"> <x-callout type="danger" title="DNS is not pointing to the right IP">
{{ $addDomainDnsMessage }} This domain does not currently resolve to this server.
@if ($serverIp) Traffic may not reach Coolify until you update DNS.
<div class="pt-2 text-sm"> Are you sure you want to add it anyway?
Expected target: @if (filled($addDomainDnsMessage))
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span> <div class="pt-2">{{ $addDomainDnsMessage }}</div>
</div>
@endif @endif
</x-callout> </x-callout>
@endif @endif
@ -202,11 +230,9 @@ class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-co
@endif @endif
@endif @endif
{{-- Edit domain modal --}} {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
@if ($showEditDomainModal) <div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
<div x-data="{ modalOpen: @entangle('showEditDomainModal') }" @keydown.window.escape="if (modalOpen) { closeEditDomain() }">
@keydown.escape.window="modalOpen = false; $wire.cancelEdit()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body"> <template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak> <div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
@ -214,7 +240,7 @@ class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]" class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.cancelEdit()"></div> @click="closeEditDomain()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center"> <div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100" x-transition:enter="ease-out duration-100"
@ -227,49 +253,56 @@ class="application-settings-form application-settings-section relative flex max-
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!"> <header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3> <h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<button type="button" wire:click="cancelEdit" class="icon-button shrink-0" <button type="button" @click="closeEditDomain()" class="icon-button shrink-0"
aria-label="Close"> aria-label="Close">
<x-reicon name="x" class="size-4" /> <x-reicon name="x" class="size-4" />
</button> </button>
</header> </header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto" <div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;"> style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4"> <form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
@php <div x-show="editingServiceLabel" x-cloak class="w-full">
$editingServiceLabel = collect($serviceApps) <div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
->firstWhere('id', (int) $editingServiceApplicationId)['name'] <label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service application</label>
?? data_get($domainRows, ($editingIndex ?? -1).'.service_name'); </div>
@endphp <input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
@if (filled($editingServiceLabel)) <p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
<x-forms.input label="Service application" value="{{ $editingServiceLabel }}" Domains stay on the service they were added to. Remove and re-add to move.
readonly </p>
helper="Domains stay on the service they were added to. Remove and re-add to move." /> </div>
@endif
<x-forms.input id="editingDomain" label="Domain URL" <div class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4" for="editingDomainLocal">
Domain URL <x-highlighted text="*" />
</label>
</div>
<input id="editingDomainLocal" type="url" class="input" required
placeholder="https://app.example.com" placeholder="https://app.example.com"
helper="Full URL including scheme. Optional path and container port are supported." x-model="localEditingDomain" />
required /> @error('editingDomain')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($editDomainDnsFailed) @if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed"> <x-callout type="danger" title="DNS is not pointing to the right IP">
{{ $editDomainDnsMessage }} This domain does not currently resolve to this server.
@if ($serverIp) Traffic may not reach Coolify until you update DNS.
<div class="pt-2 text-sm"> Are you sure you want to save it anyway?
Expected target: @if (filled($editDomainDnsMessage))
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span> <div class="pt-2">{{ $editDomainDnsMessage }}</div>
</div>
@endif @endif
</x-callout> </x-callout>
@endif @endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2"> <div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" wire:click="cancelEdit"> <x-forms.button type="button" @click="closeEditDomain()">
Cancel Cancel
</x-forms.button> </x-forms.button>
@if ($editDomainDnsFailed) @if ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="confirmUpdateDomainDespiteDns" <x-forms.button type="button" isError
isError> @click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
Continue Continue
</x-forms.button> </x-forms.button>
@else @else
@ -285,7 +318,6 @@ class="application-settings-form application-settings-section relative flex max-
</div> </div>
</template> </template>
</div> </div>
@endif
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" <x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" /> confirmAction="confirmDomainUsage" />

View file

@ -31,7 +31,7 @@
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly /> <x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
</div> </div>
</div> </div>
@if ($resource instanceof \App\Models\Application) @if ($resource instanceof \App\Models\Application && $resource->git_based())
@can('update', $resource) @can('update', $resource)
<div class="w-full sm:w-96"> <div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix" <x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
@ -47,7 +47,7 @@
<x-unsaved-bar action="submit" /> <x-unsaved-bar action="submit" />
@if (!$isReadOnly) @if (!$isReadOnly)
@can('update', $resource) @can('update', $resource)
<div class="flex gap-2"> <div class="flex flex-wrap items-center gap-2">
@if ($fileStorage->is_host_file) @if ($fileStorage->is_host_file)
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?" <x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes" buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"

View file

@ -180,8 +180,9 @@ class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-20
@foreach ($servicePageItems as $menuItem) @foreach ($servicePageItems as $menuItem)
<a @class([ <a @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'], 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}"> href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}
@ -204,8 +205,9 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
<a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}" <a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([ @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $menuItem['active'], 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}"> href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}

View file

@ -47,24 +47,32 @@ class="env-table-item">
<div @class([ <div @class([
'data-table-row', 'data-table-row',
$gridClass, $gridClass,
'opacity-90' => $isSuggested, 'domains-row-suggested' => $isSuggested,
])> ])>
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">
@if ($isSuggested)
<span
class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
title="{{ $row['url'] }} (not configured yet)">
{{ $row['url'] }}
</span>
@else
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank" <a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 font-mono text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning" class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}"> title="{{ $row['url'] }}">
{{ $row['url'] }} {{ $row['url'] }}
</a> </a>
@endif
@if ($isSuggested && ! empty($row['suggestion_label'])) @if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge shrink-0">{{ $row['suggestion_label'] }}</span> <span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif @endif
@if ($showServiceColumn) @if ($showServiceColumn)
<span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span> <span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span>
@endif @endif
</div> </div>
@if ($row['dns_status'] !== 'ok' && filled($row['dns_message'])) @if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-neutral-500 sm:truncate dark:text-fg-dim" <p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
title="{{ $row['dns_message'] }}"> title="{{ $row['dns_message'] }}">
{{ $row['dns_message'] }} {{ $row['dns_message'] }}
</p> </p>
@ -79,8 +87,13 @@ class="min-w-0 font-mono text-[13px] text-black underline decoration-neutral-300
@endif @endif
<div class="flex min-w-0 items-center"> <div class="flex min-w-0 items-center">
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType" <x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" /> :title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@endif
</div> </div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"> <div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
@ -109,12 +122,18 @@ class="h-7! px-2! text-[12px]!">
@else @else
<x-forms.button canGate="update" :canResource="$service" <x-forms.button canGate="update" :canResource="$service"
wire:click="addSuggestedDomain({{ $index }})" isHighlighted wire:click="addSuggestedDomain({{ $index }})" isHighlighted
class="h-7! px-2! text-[12px]!"> class="h-7! shrink-0 px-2.5! text-[12px]!">
Add Add domain
</x-forms.button> </x-forms.button>
@endif @endif
@else @else
<button type="button" wire:click="startEdit({{ $index }})" <button type="button"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
serviceLabel: @js($serviceLabel),
})"
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain"> class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" /> <x-reicon name="settings" class="size-3.5" />
</button> </button>

View file

@ -2,10 +2,12 @@
$hasVolumes = $this->volumeCount > 0; $hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0; $hasFiles = $this->fileCount > 0;
$hasDirectories = $this->directoryCount > 0; $hasDirectories = $this->directoryCount > 0;
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories'); $tabButtonBase = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
$tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]';
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
@endphp @endphp
<div class="flex flex-col gap-6" x-data="{ activeTab: '{{ $defaultTab }}' }"> <div class="flex flex-col gap-6">
@if ( @if (
$resource->getMorphClass() == 'App\Models\Application' || $resource->getMorphClass() == 'App\Models\Application' ||
$resource->getMorphClass() == 'App\Models\StandalonePostgresql' || $resource->getMorphClass() == 'App\Models\StandalonePostgresql' ||
@ -16,8 +18,10 @@
$resource->getMorphClass() == 'App\Models\StandaloneClickhouse' || $resource->getMorphClass() == 'App\Models\StandaloneClickhouse' ||
$resource->getMorphClass() == 'App\Models\StandaloneMongodb' || $resource->getMorphClass() == 'App\Models\StandaloneMongodb' ||
$resource->getMorphClass() == 'App\Models\StandaloneMysql') $resource->getMorphClass() == 'App\Models\StandaloneMysql')
<x-application.settings-section id="storage-mounts-section" title="Persistent storage" <x-application.settings-section id="storage-mounts-section" title="Persistent storage" :flush="true"
helper="Preview deployment volumes can use a -pr-#PRNumber suffix so each pull request receives isolated storage."> :helper="$resource instanceof \App\Models\Application && $resource->git_based()
? 'Preview deployment volumes can use a -pr-#PRNumber suffix so each pull request receives isolated storage.'
: 'Mount volumes, files, or directories to preserve data between deployments.'">
<x-slot:actions> <x-slot:actions>
@if ($resource?->build_pack !== 'dockercompose') @if ($resource?->build_pack !== 'dockercompose')
@can('update', $resource) @can('update', $resource)
@ -353,28 +357,19 @@ class="flex size-7 items-center justify-center rounded-md text-neutral-500 trans
@if ($hasVolumes || $hasFiles || $hasDirectories) @if ($hasVolumes || $hasFiles || $hasDirectories)
<div <div
class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]"> class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
<button type="button" @click="activeTab = 'volumes'" <button type="button" wire:click="setActiveTab('volumes')"
:class="activeTab === 'volumes'
? 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'"
@disabled(!$hasVolumes) @disabled(!$hasVolumes)
class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"> @class([$tabButtonBase, $activeTab === 'volumes' ? $tabButtonActive : $tabButtonInactive])>
Volumes ({{ $this->volumeCount }}) Volumes ({{ $this->volumeCount }})
</button> </button>
<button type="button" @click="activeTab = 'files'" <button type="button" wire:click="setActiveTab('files')"
:class="activeTab === 'files'
? 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'"
@disabled(!$hasFiles) @disabled(!$hasFiles)
class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"> @class([$tabButtonBase, $activeTab === 'files' ? $tabButtonActive : $tabButtonInactive])>
Files ({{ $this->fileCount }}) Files ({{ $this->fileCount }})
</button> </button>
<button type="button" @click="activeTab = 'directories'" <button type="button" wire:click="setActiveTab('directories')"
:class="activeTab === 'directories'
? 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'"
@disabled(!$hasDirectories) @disabled(!$hasDirectories)
class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"> @class([$tabButtonBase, $activeTab === 'directories' ? $tabButtonActive : $tabButtonInactive])>
Directories ({{ $this->directoryCount }}) Directories ({{ $this->directoryCount }})
</button> </button>
</div> </div>
@ -382,140 +377,109 @@ class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:
</x-slot:actions> </x-slot:actions>
@if (!$hasVolumes && !$hasFiles && !$hasDirectories) @if (!$hasVolumes && !$hasFiles && !$hasDirectories)
<x-empty title="No persistent storage" <x-empty size="sm" title="No persistent storage"
description="Add a volume, file, or directory mount to preserve data between deployments." description="Add a volume, file, or directory mount to preserve data between deployments."
icon-name="storages" /> icon-name="storages" />
@else @elseif ($activeTab === 'volumes')
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-6">
@if ($hasVolumes) @if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" /> <livewire:project.shared.storages.all wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
:resource="$resource" />
@else @else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500"> <x-empty size="sm" title="No volumes configured"
No volumes configured. description="Switch tabs or add a volume mount." icon-name="storages" />
</div>
@endif @endif
</div> @elseif ($activeTab === 'files')
<div class="flex flex-col gap-4 p-4">
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-6">
@if ($hasFiles) @if ($hasFiles)
@foreach ($this->files as $fs) @foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs" <livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" /> wire:key="file-{{ $fs->id }}" />
@endforeach @endforeach
@else @else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500"> <x-empty size="sm" title="No file mounts configured"
No file mounts configured. description="Switch tabs or add a file mount." icon-name="file" />
</div>
@endif @endif
</div> </div>
@else
{{-- Directories Tab --}} <div class="flex flex-col gap-4 p-4">
<div x-show="activeTab === 'directories'" class="flex flex-col gap-6">
@if ($hasDirectories) @if ($hasDirectories)
@foreach ($this->directories as $fs) @foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs" <livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" /> wire:key="directory-{{ $fs->id }}" />
@endforeach @endforeach
@else @else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500"> <x-empty size="sm" title="No directory mounts configured"
No directory mounts configured. description="Switch tabs or add a directory mount." icon-name="folder" />
</div>
@endif @endif
</div> </div>
@endif @endif
</x-application.settings-section> </x-application.settings-section>
@else @else
<div class="flex flex-col gap-4 py-2"> {{-- Service stack resources: one settings card + table per service --}}
<div> <x-application.settings-section :id="'storage-service-'.$resource->id"
<div class="flex items-center gap-2"> :title="Str::headline($resource->name)" :flush="true"
<h2>{{ Str::headline($resource->name) }}</h2> helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard.">
</div> <x-slot:actions>
</div>
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@php
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
$hasDirectories = $this->directoryCount > 0;
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
@endphp
@if ($hasVolumes || $hasFiles || $hasDirectories) @if ($hasVolumes || $hasFiles || $hasDirectories)
<div x-data="{ <div
activeTab: '{{ $defaultTab }}' class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
}"> <button type="button" wire:click="setActiveTab('volumes')"
{{-- Tabs Navigation --}} @disabled(!$hasVolumes)
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200"> @class([$tabButtonBase, $activeTab === 'volumes' ? $tabButtonActive : $tabButtonInactive])>
<button @click="activeTab = 'volumes'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasVolumes) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Volumes ({{ $this->volumeCount }}) Volumes ({{ $this->volumeCount }})
</button> </button>
<button @click="activeTab = 'files'" <button type="button" wire:click="setActiveTab('files')"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' : @disabled(!$hasFiles)
'border-b-2 border-transparent'" @class([$tabButtonBase, $activeTab === 'files' ? $tabButtonActive : $tabButtonInactive])>
@if (!$hasFiles) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Files ({{ $this->fileCount }}) Files ({{ $this->fileCount }})
</button> </button>
<button @click="activeTab = 'directories'" <button type="button" wire:click="setActiveTab('directories')"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' : @disabled(!$hasDirectories)
'border-b-2 border-transparent'" @class([$tabButtonBase, $activeTab === 'directories' ? $tabButtonActive : $tabButtonInactive])>
@if (!$hasDirectories) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Directories ({{ $this->directoryCount }}) Directories ({{ $this->directoryCount }})
</button> </button>
</div> </div>
{{-- Tab Content --}}
<div class="pt-4">
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
@if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" />
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No volumes configured.
</div>
@endif @endif
</div> </x-slot:actions>
{{-- Files Tab --}} @if (!$hasVolumes && !$hasFiles && !$hasDirectories)
<div x-show="activeTab === 'files'" class="flex flex-col gap-4"> <x-empty size="sm" title="No storage found"
description="No volumes, files, or directories are defined for this service."
icon-name="storages" />
@elseif ($activeTab === 'volumes')
@if ($hasVolumes)
<livewire:project.shared.storages.all
wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
:resource="$resource" />
@else
<x-empty size="sm" title="No volumes configured"
description="This service has no volume mounts." icon-name="storages" />
@endif
@elseif ($activeTab === 'files')
<div class="flex flex-col gap-4 p-4">
@if ($hasFiles) @if ($hasFiles)
@foreach ($this->files as $fs) @foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs" <livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" /> wire:key="file-{{ $fs->id }}" />
@endforeach @endforeach
@else @else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400"> <x-empty size="sm" title="No file mounts configured"
No file mounts configured. description="This service has no file mounts." icon-name="file" />
</div>
@endif @endif
</div> </div>
@else
{{-- Directories Tab --}} <div class="flex flex-col gap-4 p-4">
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
@if ($hasDirectories) @if ($hasDirectories)
@foreach ($this->directories as $fs) @foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs" <livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" /> wire:key="directory-{{ $fs->id }}" />
@endforeach @endforeach
@else @else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400"> <x-empty size="sm" title="No directory mounts configured"
No directory mounts configured. description="This service has no directory mounts." icon-name="folder" />
</div>
@endif @endif
</div> </div>
</div>
</div>
@endif @endif
</div> </x-application.settings-section>
@endif @endif
</div> </div>

View file

@ -20,7 +20,7 @@ class="listbox-panel left-auto! right-0! z-[90]! w-56! min-w-56!">
</button> </button>
@endif @endif
<button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem" <button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem"
wire:click="openDnsRecordsModal" @click="dnsEntriesOpen = false"> @click="dnsEntriesOpen = false; $dispatch('open-dns-records-modal')">
<x-reicon name="documentation" class="size-3.5 shrink-0 opacity-70" /> <x-reicon name="documentation" class="size-3.5 shrink-0 opacity-70" />
Manual records Manual records
</button> </button>
@ -97,14 +97,33 @@ class="max-h-40 space-y-1 overflow-y-auto rounded-md border border-neutral-200 b
</div> </div>
@endif @endif
@if ($showDnsRecordsModal) {{-- Always mounted so open/close is Alpine-only (no Livewire round-trip). --}}
@php @php
$dnsHints = $this->dnsRecordHints(); $dnsHints = $this->dnsRecordHints();
$dnsCopyText = $this->dnsRecordsCopyText(); $dnsCopyText = $this->dnsRecordsCopyText();
@endphp @endphp
<div x-data="{ modalOpen: @entangle('showDnsRecordsModal') }" <div
@keydown.escape.window="modalOpen = false; $wire.closeDnsRecordsModal()" x-data="{
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"> modalOpen: false,
openDnsRecords() {
this.modalOpen = true;
},
closeDnsRecords() {
this.modalOpen = false;
},
async recheckDns() {
this.modalOpen = true;
try {
await $wire.recheckDnsRecordsInModal();
} finally {
// Re-assert open after Livewire morph may re-init Alpine.
this.modalOpen = true;
}
},
}"
@open-dns-records-modal.window="openDnsRecords()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeDnsRecords() }">
<template x-teleport="body"> <template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak> <div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
@ -112,7 +131,7 @@ class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]" class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.closeDnsRecordsModal()"></div> @click="closeDnsRecords()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center"> <div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100" x-transition:enter="ease-out duration-100"
@ -125,7 +144,7 @@ class="application-settings-form application-settings-section relative flex w-fu
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!"> <header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">DNS entries</h3> <h3 class="min-w-0 flex-1 truncate">DNS entries</h3>
<button type="button" wire:click="closeDnsRecordsModal" <button type="button" @click="closeDnsRecords()"
class="icon-button shrink-0" aria-label="Close"> class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" /> <x-reicon name="x" class="size-4" />
</button> </button>
@ -158,7 +177,7 @@ class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:b
</thead> </thead>
<tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300"> <tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300">
@foreach ($dnsHints as $record) @foreach ($dnsHints as $record)
<tr class="font-mono text-[13px] text-black dark:text-fg"> <tr class="text-[13px] text-black dark:text-fg">
<td class="px-3 py-2.5">{{ $record['type'] }}</td> <td class="px-3 py-2.5">{{ $record['type'] }}</td>
<td class="px-3 py-2.5"> <td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [ @include('livewire.project.shared.partials.dns-copy-cell', [
@ -209,9 +228,10 @@ class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:b
<p class="text-[12px] text-neutral-500 dark:text-fg-dim"> <p class="text-[12px] text-neutral-500 dark:text-fg-dim">
{{ count($dnsHints) }} {{ count($dnsHints) }}
{{ count($dnsHints) === 1 ? 'entry' : 'entries' }} {{ count($dnsHints) === 1 ? 'entry' : 'entries' }}
· Type / Name / Value · BIND zone format
</p> </p>
<button type="button" class="button shrink-0" <button type="button" class="button shrink-0"
title="Copy as BIND-compatible zone file"
@click.prevent="copyAll(@js($dnsCopyText))"> @click.prevent="copyAll(@js($dnsCopyText))">
<span x-text="copied ? 'Copied' : 'Copy all'"></span> <span x-text="copied ? 'Copied' : 'Copy all'"></span>
</button> </button>
@ -220,13 +240,13 @@ class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:b
@endif @endif
<div class="flex flex-wrap items-center justify-between gap-2 pt-2"> <div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<x-forms.button type="button" wire:click="recheckDnsRecordsInModal" <x-forms.button type="button" @click="recheckDns()"
wire:target="recheckDnsRecordsInModal,checkAllDns,checkDomainDns" wire:target="recheckDnsRecordsInModal,checkAllDns,checkDomainDns"
title="Recheck DNS"> title="Recheck DNS">
<x-reicon name="refresh" class="size-3.5" /> <x-reicon name="refresh" class="size-3.5" />
Recheck Recheck
</x-forms.button> </x-forms.button>
<x-forms.button type="button" wire:click="closeDnsRecordsModal" isHighlighted> <x-forms.button type="button" @click="closeDnsRecords()" isHighlighted>
Done Done
</x-forms.button> </x-forms.button>
</div> </div>
@ -235,5 +255,4 @@ class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:b
</div> </div>
</div> </div>
</template> </template>
</div> </div>
@endif

View file

@ -1,3 +1,17 @@
@php
$showEnvironmentType = $showPreview;
$activeFilterCount = count($variableFilters) + count($serviceFilters) + ($environmentFilter !== 'all' ? 1 : 0);
$filterLabels = [
'managed' => 'Managed', 'user' => 'User-defined', 'buildtime' => 'Buildtime',
'runtime' => 'Runtime', 'multiline' => 'Multiline', 'literal' => 'Literal',
];
$activeFilterLabels = collect($variableFilters)->map(fn ($filter) => $filterLabels[$filter] ?? $filter);
$activeFilterLabels = $activeFilterLabels->merge($serviceFilters);
if ($environmentFilter !== 'all') {
$activeFilterLabels->push(str($environmentFilter)->headline()->toString());
}
$activeFilterText = $activeFilterLabels->implode(', ');
@endphp
<div class="flex flex-col gap-4" wire:init="loadEnvironmentVariables"> <div class="flex flex-col gap-4" wire:init="loadEnvironmentVariables">
<x-application.settings-section id="environment-variables-section" title="Environment variables" <x-application.settings-section id="environment-variables-section" title="Environment variables"
helper="Environment variables (secrets) for this resource."> helper="Environment variables (secrets) for this resource.">
@ -77,40 +91,114 @@ class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none"
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 sm:ml-auto"> <div class="flex flex-wrap items-center gap-2 sm:ml-auto">
@if ($resource->type() === 'application' && $showPreview)
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false"> <div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false" <button type="button" @click="open = !open" @click.outside="open = false"
aria-haspopup="listbox" :aria-expanded="open" @disabled(! $readyToLoad)> @if ($activeFilterCount > 0) title="{{ $activeFilterText }}" @endif
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" @class([
stroke="currentColor" class="size-3.5"> 'button max-w-80 min-w-0',
<path stroke-linecap="round" stroke-linejoin="round" 'bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 dark:bg-warning/15! dark:text-warning! dark:ring-warning/25' => $activeFilterCount > 0,
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /> ])>
</svg> <x-reicon name="filter" class="size-3.5 shrink-0" />
Filter <span class="truncate">{{ $activeFilterCount > 0 ? $activeFilterText : 'Filter' }}</span>
@if ($activeFilterCount > 0)
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeFilterCount }}</span>
@endif
</button> </button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-48!" x-show="open" x-cloak <div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open" x-cloak>
role="listbox"> <div class="max-h-80 overflow-y-auto p-1">
@foreach ([ @foreach ([
'all' => 'All environments', 'managed' => 'Managed',
'production' => 'Production', 'user' => 'User-defined',
'preview' => 'Preview', 'buildtime' => 'Buildtime',
] as $filterValue => $filterLabel) 'runtime' => 'Runtime',
<button type="button" class="listbox-option" role="option" 'multiline' => 'Multiline',
aria-selected="{{ $environmentFilter === $filterValue ? 'true' : 'false' }}" 'literal' => 'Literal',
wire:click="setEnvironmentFilter('{{ $filterValue }}')" @click="open = false"> ] as $value => $label)
<span class="truncate">{{ $filterLabel }}</span> <button type="button" class="listbox-option" wire:click="toggleVariableFilter('{{ $value }}')">
@if ($environmentFilter === $filterValue) <span>{{ $label }}</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" @php
stroke-width="2.5" stroke="currentColor" class="size-3.5 shrink-0"> $selected = in_array($value, $variableFilters, true);
<path stroke-linecap="round" stroke-linejoin="round" @endphp
d="m4.5 12.75 6 6 9-13.5" /> <span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
])>
@if ($selected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
@endif @endif
</span>
</button>
@endforeach
@if ($showPreview)
<div class="my-1 border-t border-neutral-200 dark:border-white/10"></div>
@foreach (['all' => 'All environments', 'production' => 'Production', 'preview' => 'Preview'] as $value => $label)
<button type="button" class="listbox-option" wire:click="setEnvironmentFilter('{{ $value }}')" @click="open = false">
<span>{{ $label }}</span>
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $environmentFilter === $value,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => $environmentFilter !== $value,
])>
@if ($environmentFilter === $value)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
@if ($this->serviceFilterOptions !== [])
<div class="my-1 border-t border-neutral-200 dark:border-white/10"></div>
<div class="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-neutral-400 dark:text-fg-faint">Services</div>
@foreach ($this->serviceFilterOptions as $serviceName)
<button type="button" class="listbox-option" wire:click="toggleServiceFilter(@js($serviceName))">
<span class="truncate">{{ $serviceName }}</span>
@php
$serviceSelected = in_array($serviceName, $serviceFilters, true);
@endphp
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $serviceSelected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $serviceSelected,
])>
@if ($serviceSelected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
</div>
<div class="relative z-20 border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-[#171717]">
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
wire:click="clearFilters" @click="open = false" @disabled($activeFilterCount === 0)>
<span>Clear filters</span>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
</div>
</div>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false">
Sort
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak>
@foreach (['default' => 'Default order', 'name_asc' => 'Name AZ', 'name_desc' => 'Name ZA'] as $value => $label)
<button type="button" class="listbox-option" wire:click="setTableSort('{{ $value }}')" @click="open = false">
<span>{{ $label }}</span>
@if ($tableSort === $value)<span></span>@endif
</button> </button>
@endforeach @endforeach
</div> </div>
</div> </div>
@endif
@can('manageEnvironment', $resource) @can('manageEnvironment', $resource)
{{-- Do not disable Add based on readyToLoad: modal-input uses wire:ignore, so a {{-- Do not disable Add based on readyToLoad: modal-input uses wire:ignore, so a
disabled attribute painted on first load would never re-enable. --}} disabled attribute painted on first load would never re-enable. --}}
@ -145,18 +233,22 @@ class="application-settings-section-body mt-1 flex min-h-40 w-full scroll-mt-28
$lastVisibleRow = min($currentPage * $perPage, $totalRows); $lastVisibleRow = min($currentPage * $perPage, $totalRows);
@endphp @endphp
<div id="environment-table-section" <div id="environment-table-section"
class="application-settings-section-body mt-1 scroll-mt-28 {{ $totalRows > 0 ? 'is-flush' : '' }} w-full"> class="application-settings-section-body relative mt-1 scroll-mt-28 {{ $totalRows > 0 ? 'is-flush' : '' }} w-full">
@if ($this->isSearchActive && $totalRows === 0) @if ($this->isSearchActive && $totalRows === 0)
<x-empty size="sm" title="No environment variables found" <x-empty size="sm" title="No environment variables found"
description="No variables match your search." /> description="No variables match your search." />
@elseif ($totalRows > 0) @elseif ($totalRows > 0)
<div class="data-table w-full transition-opacity" <div class="data-table w-full">
wire:loading.class="opacity-50 pointer-events-none" <div class="relative">
wire:target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"> <div class="transition-all"
<div class="data-table-header env-table-grid"> wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
<div class="data-table-header env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
<span>Name</span> <span>Name</span>
<span class="text-center">Managed</span>
@if ($showEnvironmentType)
<span>Type</span> <span>Type</span>
<span>Comment</span> @endif
<span class="text-center">Literal</span> <span class="text-center">Literal</span>
<span class="text-center">Multiline</span> <span class="text-center">Multiline</span>
<span class="text-center">Buildtime</span> <span class="text-center">Buildtime</span>
@ -166,13 +258,20 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $totalRows > 0 ? '
@foreach ($this->environmentVariablePageRows as $row) @foreach ($this->environmentVariablePageRows as $row)
@if ($row['kind'] === 'managed') @if ($row['kind'] === 'managed')
<livewire:project.shared.environment-variable.show wire:key="{{ $row['id'] }}" <livewire:project.shared.environment-variable.show wire:key="{{ $row['id'] }}"
:env="$row['environmentVariable']" :type="$resource->type()" /> :env="$row['environmentVariable']" :type="$resource->type()" :showEnvironmentType="$showEnvironmentType" />
@else @else
<livewire:project.shared.environment-variable.show-hardcoded <livewire:project.shared.environment-variable.show-hardcoded
wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']" wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']"
:isPreview="$row['scope'] === 'preview'" /> :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" />
@endif @endif
@endforeach @endforeach
</div>
<div wire:loading.flex
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
<x-loading text="Loading environment variables..." />
</div>
</div>
<x-table-pagination :from="$firstVisibleRow" :to="$lastVisibleRow" :total="$totalRows" <x-table-pagination :from="$firstVisibleRow" :to="$lastVisibleRow" :total="$totalRows"
:current-page="$currentPage" :last-page="$lastPage" :current-page="$currentPage" :last-page="$lastPage"
wire-target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage" wire-target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
@ -182,9 +281,17 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $totalRows > 0 ? '
last-action="setEnvironmentVariablePage({{ $lastPage }})" /> last-action="setEnvironmentVariablePage({{ $lastPage }})" />
</div> </div>
@else @else
<div class="relative min-h-40">
<div wire:loading.class="pointer-events-none opacity-40 blur-[2px]" wire:target="clearFilters">
<x-empty size="sm" title="No environment variables" <x-empty size="sm" title="No environment variables"
description="Add your first variable with the + Add button above." description="Add your first variable with the + Add button above."
icon-name="variables" /> icon-name="variables" />
</div>
<div wire:loading.flex wire:target="clearFilters"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
<x-loading text="Loading environment variables..." />
</div>
</div>
@endif @endif
</div> </div>
@endif @endif

View file

@ -1,24 +1,47 @@
<div class="env-table-item" <div class="env-table-item"
x-show="typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $isPreview ? 'preview' : 'production' }}'"> x-show="typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $isPreview ? 'preview' : 'production' }}'">
<div class="data-table-row env-table-grid"> <div class="data-table-row env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg" title="{{ $key }}">{{ $key }}</span> <span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg" title="{{ $key }}">{{ $key }}</span>
<span class="table-badge shrink-0">Hardcoded</span> @if (filled($comment))
<x-helper :helper="e($comment)" />
@endif
@if ($serviceName) @if ($serviceName)
<span class="table-badge shrink-0">{{ $serviceName }}</span> <span class="table-badge shrink-0">{{ $serviceName }}</span>
@endif @endif
<span class="env-type-mobile table-badge shrink-0">{{ $isPreview ? 'Preview' : 'Production' }}</span>
</div> </div>
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim"> <span class="env-managed-desktop data-table-cell-check">
{{ $isPreview ? 'Preview' : 'Production' }} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
@if ($showEnvironmentType)
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ $isPreview ? 'Preview' : 'Production' }}</div>
@endif
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<div class="justify-self-end">
<x-modal-input title="Environment variable details" :closeOutside="false">
<x-slot:content>
<button type="button" class="icon-button shrink-0"
title="View environment variable" aria-label="View environment variable">
<x-reicon name="settings" class="size-3.5" />
</button>
</x-slot:content>
<div class="flex w-full flex-col gap-4">
<x-forms.input label="Name" :value="$key" readonly />
<x-forms.input label="Value" :value="$value ?? ''" readonly />
@if (filled($comment))
<x-forms.input label="Comment" :value="$comment" readonly />
@endif
<x-callout type="info" title="Managed by Docker Compose">
Update this value in the Compose file.
</x-callout>
</div> </div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"> </x-modal-input>
{{ $comment ?: ($value !== null && $value !== '' ? '-' : 'Inherited from host') }}
</div> </div>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<div></div>
</div> </div>
</div> </div>

View file

@ -12,7 +12,7 @@
@if ($isSharedVariable) :style="`order: ${sharedSort === 'alphabetical' ? {{ $tableAlphabeticalOrder }} : {{ $tableCreationOrder }}}`" @endif @if ($isSharedVariable) :style="`order: ${sharedSort === 'alphabetical' ? {{ $tableAlphabeticalOrder }} : {{ $tableCreationOrder }}}`" @endif
x-show="(typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $rowScope }}') x-show="(typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $rowScope }}')
&& (typeof sharedSearch === 'undefined' || @js(mb_strtolower($env->key . ' ' . ($comment ?? '') . ' ' . $rowScopeLabel)).includes(sharedSearch.trim().toLowerCase()))"> && (typeof sharedSearch === 'undefined' || @js(mb_strtolower($env->key . ' ' . ($comment ?? '') . ' ' . $rowScopeLabel)).includes(sharedSearch.trim().toLowerCase()))">
<div class="data-table-row {{ $isSharedVariable ? 'env-table-grid-shared' : 'env-table-grid' }}"> <div class="data-table-row {{ $isSharedVariable ? 'env-table-grid-shared' : 'env-table-grid' }} {{ ! $isSharedVariable && ! $showEnvironmentType ? 'env-table-grid-no-type' : '' }}">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
@if ($isLocked) @if ($isLocked)
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" <svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
@ -26,21 +26,34 @@
@endif @endif
<span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg" <span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg"
title="{{ $env->key }}">{{ $env->key }}</span> title="{{ $env->key }}">{{ $env->key }}</span>
@if (! $isSharedVariable && filled($comment))
<x-helper :helper="e($comment)" />
@endif
@if ($is_really_required) @if ($is_really_required)
<span class="table-badge table-badge-danger shrink-0">Required</span> <span class="table-badge table-badge-danger shrink-0">Required</span>
@endif @endif
</div>
@if (! $isSharedVariable)
@if ($isMagicVariable) @if ($isMagicVariable)
<span class="table-badge shrink-0">Managed</span> <span class="env-managed-desktop data-table-cell-check">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
@else
<span class="env-managed-desktop data-table-cell-dash">-</span>
@endif @endif
<span class="env-type-mobile table-badge shrink-0">{{ $rowScopeLabel }}</span> @endif
</div> @if ($showEnvironmentType)
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim"> <div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ $rowScopeLabel }}</div>
{{ $rowScopeLabel }} @endif
</div> @if ($isSharedVariable)
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim" <div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@if ($comment) title="{{ $comment }}" @endif> @if ($comment) title="{{ $comment }}" @endif>
{{ $comment ?: '-' }} {{ $comment ?: '-' }}
</div> </div>
@endif
@if ($isSharedVariable) @if ($isSharedVariable)
@if ($is_multiline) @if ($is_multiline)
<span class="data-table-cell-check"> <span class="data-table-cell-check">

View file

@ -5,7 +5,7 @@
@if ($type === 'application') @if ($type === 'application')
<livewire:project.shared.configuration-checker :resource="$resource" /> <livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.application.heading :application="$resource" /> <livewire:project.application.heading :application="$resource" wire:key="application-heading-command" />
@elseif ($type === 'database') @elseif ($type === 'database')
<livewire:project.shared.configuration-checker :resource="$resource" /> <livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.database.heading :database="$resource" /> <livewire:project.database.heading :database="$resource" />

View file

@ -6,7 +6,7 @@
<livewire:project.shared.configuration-checker :resource="$resource" /> <livewire:project.shared.configuration-checker :resource="$resource" />
@if ($type === 'application') @if ($type === 'application')
<livewire:project.application.heading :application="$resource" /> <livewire:project.application.heading :application="$resource" wire:key="application-heading-logs" />
@elseif ($type === 'database') @elseif ($type === 'database')
<livewire:project.database.heading :database="$resource" /> <livewire:project.database.heading :database="$resource" />
@elseif ($type === 'service') @elseif ($type === 'service')

View file

@ -3,6 +3,13 @@
<x-application.settings-section id="cpu-limits-section" title="CPU" <x-application.settings-section id="cpu-limits-section" title="CPU"
helper="Limit CPU capacity, affinity, and scheduling priority for this container."> helper="Limit CPU capacity, affinity, and scheduling priority for this container.">
<x-slot:actions>
<a class="button" target="_blank" rel="noopener noreferrer"
href="https://docs.docker.com/engine/containers/resource_constraints/#cpu">
Docker CPU constraints
<x-reicon name="external-link" class="size-3.5" />
</a>
</x-slot:actions>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<x-forms.input canGate="update" :canResource="$resource" placeholder="1.5" <x-forms.input canGate="update" :canResource="$resource" placeholder="1.5"
helper="Set to 0 to use all available CPUs. Decimal values such as 0.5 are supported." helper="Set to 0 to use all available CPUs. Decimal values such as 0.5 are supported."
@ -14,12 +21,6 @@
helper="Relative CPU scheduling weight. Docker uses 1024 by default." helper="Relative CPU scheduling weight. Docker uses 1024 by default."
label="CPU weight" id="limitsCpuShares" /> label="CPU weight" id="limitsCpuShares" />
</div> </div>
<a class="mt-4 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-coollabs dark:text-fg-dim dark:hover:text-warning"
target="_blank" rel="noopener noreferrer"
href="https://docs.docker.com/engine/containers/resource_constraints/#cpu">
Docker CPU constraints
<x-external-link />
</a>
</x-application.settings-section> </x-application.settings-section>
<x-application.settings-section id="memory-limits-section" title="Memory" <x-application.settings-section id="memory-limits-section" title="Memory"

View file

@ -110,7 +110,8 @@
<x-forms.listbox id="clone-resource-destination" label="Network destination" :wire="false" <x-forms.listbox id="clone-resource-destination" label="Network destination" :wire="false"
x-model="selectedCloneDestination" x-effect="options = cloneDestinationOptions" x-model="selectedCloneDestination" x-effect="options = cloneDestinationOptions"
x-bind:disabled="!selectedCloneServer" placeholder="Choose a destination…" /> x-bind:disabled="!selectedCloneServer" placeholder="Choose a destination…"
emptyText="No network destinations are available on this server." />
</div> </div>
<div x-show="selectedCloneDestination" x-cloak <div x-show="selectedCloneDestination" x-cloak

View file

@ -1,16 +1,223 @@
<div class="flex flex-col gap-6"> @php
@if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose') $gridClass = match (true) {
<div class="w-full rounded-lg bg-warning/10 p-2 text-sm text-warning"> $supportsPreviewSuffix => 'volumes-table-grid-with-pr',
For docker compose based applications Volume mounts are read-only in the Coolify dashboard. To add, modify, or manage volumes, you must edit your Docker Compose file and reload the compose file. $showActionsColumn => 'volumes-table-grid',
default => 'volumes-table-grid-readonly',
};
@endphp
<div class="flex w-full flex-col">
@if ($isComposeOrService)
<div
class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-white/[0.08] dark:text-amber-300/90">
@if ($resource->type() === 'service')
Service volume mounts are read-only here. Edit the Docker Compose file and reload it to change volumes.
@else
Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
@endif
</div> </div>
@endif @endif
@foreach ($resource->persistentStorages as $storage)
@if ($resource->type() === 'service') @if ($resource->persistentStorages->isNotEmpty())
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage" <div class="data-table w-full">
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' /> <div class="data-table-header {{ $gridClass }}">
<span>Volume Name</span>
<span class="volumes-col-source">Source Path</span>
<span>Destination Path</span>
@if ($supportsPreviewSuffix)
<span class="volumes-col-pr"
title="Whether preview deployments receive an isolated -pr-N volume suffix.">
PR suffix
</span>
@endif
@if ($showActionsColumn)
<span class="volumes-col-actions text-right">Actions</span>
@endif
</div>
@foreach ($this->storages as $storage)
@php
$id = $storage->id;
$form = $forms[$id] ?? null;
if (! $form) {
continue;
}
$backupMeta = $volumeBackupMeta[$id] ?? ['enabled' => false, 'url' => null];
$hasEnabledBackup = $backupMeta['enabled'];
$backupUrl = $backupMeta['url'];
$inputsReadonly = $form['isReadOnly'];
$displayHostPath = filled($form['hostPath']) ? $form['hostPath'] : '—';
@endphp
@if ($inputsReadonly)
<div class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<span
class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else @else
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage" <span class="table-badge table-badge-success shrink-0"
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" /> title="Volume backup is enabled">Backup</span>
@endif
@endif
</div>
</div>
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<span class="block min-w-0 truncate font-mono text-[13px]"
title="{{ $form['hostPath'] }}">{{ $displayHostPath }}</span>
</div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<span
class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-fg"
title="{{ $form['mountPath'] }}">{{ $form['mountPath'] }}</span>
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<span>{{ $form['isPreviewSuffixEnabled'] ? 'Add suffix' : 'Share volume' }}</span>
</div>
@endif
@if ($showActionsColumn)
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@if ($canUpdate)
<x-forms.button type="button" wire:click="openBackupModal({{ $id }})"
class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@else
<span class="text-neutral-400 dark:text-fg-faint"></span>
@endif
</div>
@endif
</div>
</div>
@else
<form wire:submit="submit({{ $id }})" class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }}">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.name" required />
</div>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div>
</div>
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<x-forms.input id="forms.{{ $id }}.hostPath" placeholder="Host path (optional)" />
</div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="forms.{{ $id }}.mountPath" required
placeholder="/path/in/container" />
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endif
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs">
Update
</x-forms.button>
@if ($resource instanceof \App\Models\Application)
<x-forms.button type="button" wire:click="openBackupModal({{ $id }})"
class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete({{ $id }})" :actions="[
'The selected persistent storage/volume will be permanently deleted.',
'If the persistent storage/volume is actvily used by a resource data will be lost.',
]" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
</div>
</div>
</form>
@endif @endif
@endforeach @endforeach
</div>
@endif
{{-- Single shared backup configurator (mounted only when opened) --}}
@if ($backupModalStorageId && $resource instanceof \App\Models\Application)
<div wire:key="shared-volume-backup-modal-{{ $backupModalStorageId }}" x-data="{ modalOpen: true }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.closeBackupModal() } })"
@keydown.window.escape="modalOpen = false">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center"
@click.self="modalOpen = false">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Configure Volume Backup</h3>
<button type="button" @click="modalOpen = false"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $backupModalStorageId"
wire:key="shared-configure-volume-backup-{{ $backupModalStorageId }}" />
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div> </div>

View file

@ -1,141 +1,148 @@
<div> @php
<form wire:submit='submit' class="flex flex-col gap-4"> $showActionsColumn = $resource instanceof \App\Models\Application;
@if ($isReadOnly) $gridClass = match (true) {
@if (!$storage->isServiceResource() && !$storage->isDockerComposeResource()) $supportsPreviewSuffix => 'volumes-table-grid-with-pr',
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning"> $showActionsColumn => 'volumes-table-grid',
This volume is mounted as read-only and cannot be modified from the UI. default => 'volumes-table-grid-readonly',
</div> };
@endif $canUpdate = auth()->user()?->can('update', $resource) ?? false;
@if ($isFirst) $inputsReadonly = $isReadOnly || ! $canUpdate;
<div class="grid w-full gap-4 md:grid-cols-3"> $displayHostPath = filled($hostPath) ? $hostPath : '—';
@if ( @endphp
$storage->resource_type === 'App\Models\ServiceApplication' ||
$storage->resource_type === 'App\Models\ServiceDatabase') @if ($inputsReadonly)
<x-forms.input id="name" label="Volume Name" required readonly {{-- Read-only: plain data-table row (service / compose / no permission) --}}
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing."> <div class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
<x-slot:labelSuffix> <div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $name }}">{{ $name }}</span>
@if ($hasEnabledBackup) @if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl" @if ($backupUrl)
status="Backup enabled" type="success" <a href="{{ $backupUrl }}"
:class="$backupUrl ? 'cursor-pointer underline' : null" /> class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
@endif title="Volume backup is enabled">
</x-slot:labelSuffix> Backup
</x-forms.input> </a>
@else @else
<x-forms.input id="name" label="Volume Name" required readonly <span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing."> Backup
<x-slot:labelSuffix> </span>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif @endif
</x-slot:labelSuffix>
</x-forms.input>
@endif
@if ($isService || $startedAt)
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
label="Source Path"
helper="Warning: Changing the source path after the initial start could cause problems. Only use it when you know what are you doing." />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required readonly />
@else
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
label="Source Path"
helper="Warning: Changing the source path after the initial start could cause problems. Only use it when you know what are you doing." />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required readonly />
@endif @endif
</div> </div>
@else </div>
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required readonly> <div class="volumes-col-source min-w-0">
<x-slot:labelSuffix> <span class="volumes-mobile-label volumes-field-label">Source Path</span>
@if ($hasEnabledBackup) <span class="block min-w-0 truncate font-mono text-[13px]" title="{{ $hostPath }}">
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl" {{ $displayHostPath }}
status="Backup enabled" type="success" </span>
:class="$backupUrl ? 'cursor-pointer underline' : null" /> </div>
@endif
</x-slot:labelSuffix> <div class="volumes-cell-dest min-w-0">
</x-forms.input> <span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="hostPath" readonly /> <span class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-fg"
<x-forms.input id="mountPath" required readonly /> title="{{ $mountPath }}">{{ $mountPath }}</span>
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<span>{{ $isPreviewSuffixEnabled ? 'Add suffix' : 'Share volume' }}</span>
</div> </div>
@endif @endif
@if (!$isService)
@can('update', $resource) @if ($showActionsColumn)
<div class="w-full sm:w-96"> <div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix" @if ($canUpdate)
helper="Choose whether preview deployments receive an isolated -pr-N volume suffix." @if ($showBackupModal)
onChange="instantSave" :options="[ <x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
['value' => true, 'label' => 'Add suffix'], wireOpen="showBackupModal">
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endcan
@endif
@if ($resource instanceof \App\Models\Application)
@can('update', $resource)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource" <livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id" :selected-target-key="'volume:' . $storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" /> wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input> </x-modal-input>
@endcan @else
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif @endif
@else @else
@can('update', $resource) <span class="text-neutral-400 dark:text-fg-faint"></span>
@if ($isFirst)
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" label="Volume Name" required>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif @endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" helper="Directory on the host system." label="Source Path" />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required />
</div> </div>
@endif
</div>
</div>
@else
{{-- Editable volume row --}}
<form wire:submit="submit" class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
<div class="data-table-row {{ $gridClass }}">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1">
<x-forms.input id="name" required />
</div>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">
Backup
</a>
@else @else
<div class="grid w-full gap-4 md:grid-cols-3"> <span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required> Backup
<x-slot:labelSuffix> </span>
@if ($hasEnabledBackup) @endif
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif @endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" />
<x-forms.input id="mountPath" required />
</div> </div>
@endif </div>
@if (!$isService)
<div class="w-full sm:w-96"> <div class="volumes-col-source min-w-0">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix" <span class="volumes-mobile-label volumes-field-label">Source Path</span>
helper="Choose whether preview deployments receive an isolated -pr-N volume suffix." <x-forms.input id="hostPath" placeholder="Host path (optional)" />
onChange="instantSave" :options="[ </div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="mountPath" required placeholder="/path/in/container" />
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<x-forms.listbox id="isPreviewSuffixEnabled" onChange="instantSave" :options="[
['value' => true, 'label' => 'Add suffix'], ['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'], ['value' => false, 'label' => 'Share volume'],
]" /> ]" />
</div> </div>
@endif @endif
<div class="flex gap-2">
<x-forms.button type="submit"> <div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs">
Update Update
</x-forms.button> </x-forms.button>
@if ($resource instanceof \App\Models\Application) @if ($resource instanceof \App\Models\Application)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false"> @if ($showBackupModal)
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
wireOpen="showBackupModal">
<livewire:project.application.backup.create :application="$resource" <livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id" :selected-target-key="'volume:' . $storage->id"
wire:key="configure-volume-backup-{{ $storage->id }}" /> wire:key="configure-volume-backup-{{ $storage->id }}" />
</x-modal-input> </x-modal-input>
@else
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif @endif
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete" <x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="[ submitAction="delete" :actions="[
'The selected persistent storage/volume will be permanently deleted.', 'The selected persistent storage/volume will be permanently deleted.',
@ -144,39 +151,6 @@
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below" confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" /> shortConfirmationLabel="Storage Name" />
</div> </div>
@else
@if ($isFirst)
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" label="Volume Name" required disabled>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" helper="Directory on the host system." label="Source Path"
disabled />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required disabled />
</div> </div>
@else
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required disabled>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" disabled />
<x-forms.input id="mountPath" required disabled />
</div>
@endif
@endcan
@endif
</form> </form>
</div> @endif

View file

@ -288,8 +288,9 @@ class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-20
@foreach ($serverMenuItems as $menuItem) @foreach ($serverMenuItems as $menuItem)
<a @class([ <a @class([
'app-tab shrink-0', 'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'], 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}"> href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}
@ -308,8 +309,9 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}" <a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([ @class([
'app-tab shrink-0 gap-1', 'app-tab shrink-0 gap-1',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $menuItem['active'], 'app-tab-active' => $menuItem['active'],
]) ])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}"> href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }} {{ $menuItem['label'] }}

View file

@ -12,18 +12,10 @@
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" /> <link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv @endenv
@auth @auth
@php
$pusherForceWs = (bool) config('constants.pusher.force_ws');
@endphp
<script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script> <script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script> <script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script>
<script> <script>
window.Pusher = Pusher; window.Pusher = Pusher;
@if ($pusherForceWs)
if (window.Pusher && window.Pusher.Runtime) {
window.Pusher.Runtime.getProtocol = function () { return 'http:'; };
}
@endif
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default; const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
window.Echo = new EchoConstructor({ window.Echo = new EchoConstructor({
broadcaster: 'pusher', broadcaster: 'pusher',
@ -33,10 +25,10 @@
wsPort: "{{ getRealtime() }}", wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}", wssPort: "{{ getRealtime() }}",
forceTLS: false, forceTLS: false,
encrypted: @json($pusherForceWs ? false : true), encrypted: true,
enableStats: false, enableStats: false,
enableLogging: @json(app()->environment('local')), enableLogging: @json(app()->environment('local')),
enabledTransports: @json($pusherForceWs ? ['ws'] : ['ws', 'wss']), enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'], disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
}); });
</script> </script>

View file

@ -189,7 +189,8 @@
$this->application->refresh(); $this->application->refresh();
expect($this->application->fqdn)->toBe('https://app.example.com'); expect(explode(',', (string) $this->application->fqdn))
->toBe(['https://app.example.com', 'https://www.app.example.com']);
}); });
it('adds multiple domains without replacing existing ones', function () { it('adds multiple domains without replacing existing ones', function () {
@ -218,7 +219,8 @@
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
->call('addDomain') ->call('addDomain')
->assertSet('addDomainDnsFailed', true) ->assertSet('addDomainDnsFailed', true)
->assertSee('DNS validation failed'); ->assertSee('DNS is not pointing to the right IP')
->assertSee('Are you sure you want to add it anyway');
$this->application->refresh(); $this->application->refresh();
expect($this->application->fqdn)->toBeNull(); expect($this->application->fqdn)->toBeNull();
@ -229,7 +231,10 @@
->assertDispatched('close-modal'); ->assertDispatched('close-modal');
$this->application->refresh(); $this->application->refresh();
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid'); expect(explode(',', (string) $this->application->fqdn))->toBe([
'https://this-domain-should-not-resolve-for-coolify-tests.invalid',
'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid',
]);
}); });
it('resets the dns gate when the domain input changes', function () { it('resets the dns gate when the domain input changes', function () {
@ -281,7 +286,8 @@
->call('updateDomain') ->call('updateDomain')
->assertSet('editDomainDnsFailed', true) ->assertSet('editDomainDnsFailed', true)
->assertSet('showEditDomainModal', true) ->assertSet('showEditDomainModal', true)
->assertSee('DNS validation failed'); ->assertSee('DNS is not pointing to the right IP')
->assertSee('Are you sure you want to save it anyway');
$this->application->refresh(); $this->application->refresh();
expect($this->application->fqdn)->toBe('https://old.example.com'); expect($this->application->fqdn)->toBe('https://old.example.com');
@ -367,6 +373,101 @@
->toContain('https://www.example.com'); ->toContain('https://www.example.com');
}); });
it('does not re-add a removed www counterpart on page load when redirect is www', function () {
$this->application->update([
'fqdn' => 'https://asd.hu',
'redirect' => 'www',
]);
// Mount alone must not re-add missing pairs (would undo deletes).
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.url', 'https://asd.hu');
expect(explode(',', (string) $this->application->fresh()->fqdn))
->toContain('https://asd.hu')
->not->toContain('https://www.asd.hu');
});
it('keeps a domain removed when its www counterpart remains and redirect is www', function () {
$this->application->update([
'fqdn' => 'https://asd.hu,https://www.asd.hu',
'redirect' => 'www',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('removeDomain', 0)
->assertDispatched('success');
$this->application->refresh();
expect(explode(',', (string) $this->application->fqdn))
->toContain('https://www.asd.hu')
->not->toContain('https://asd.hu');
});
it('auto-adds www pair when adding a domain while redirect is www', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = false;
$settings->save();
$this->application->update([
'fqdn' => null,
'redirect' => 'www',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
$this->application->refresh();
expect(explode(',', (string) $this->application->fqdn))
->toContain('https://app.example.com')
->toContain('https://www.app.example.com');
});
it('auto-adds the suggested www pair when adding a domain with both directions', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = false;
$settings->save();
$this->application->update([
'fqdn' => null,
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
expect(explode(',', (string) $this->application->fresh()->fqdn))
->toContain('https://app.example.com')
->toContain('https://www.app.example.com');
});
it('appends a generated domain without replacing existing domains', function () {
$this->application->update([
'fqdn' => 'https://existing.example.com,https://www.existing.example.com',
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('generateDomain')
->assertDispatched('success')
->assertNotDispatched('error');
$domains = explode(',', (string) $this->application->fresh()->fqdn);
expect($domains)
->toContain('https://existing.example.com')
->toContain('https://www.existing.example.com')
->toHaveCount(3);
});
it('auto-adds missing non-www counterpart as a normal domain when setting non-www redirect', function () { it('auto-adds missing non-www counterpart as a normal domain when setting non-www redirect', function () {
$this->application->update([ $this->application->update([
'fqdn' => 'https://www.example.com', 'fqdn' => 'https://www.example.com',
@ -479,7 +580,25 @@
Livewire::test(Domains::class, ['application' => $this->application->fresh()]) Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.dns_status', 'failed') ->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.0.dns_message', 'DNS does not point to 203.0.113.10.') ->assertSet('domainRows.0.dns_message', 'DNS does not point to 203.0.113.10.')
->assertSee('DNS does not point to 203.0.113.10.'); ->assertSee('DNS mismatch')
->assertDontSee('DNS does not point to 203.0.113.10.')
->call('openDnsRecordsModal')
->assertSet('showDnsRecordsModal', true);
});
it('shows dns mismatches before other domain entries', function () {
$this->application->update([
'fqdn' => 'https://healthy.example.com,https://broken.example.com',
'domain_dns_statuses' => [
'https://healthy.example.com' => ['status' => 'ok', 'message' => 'OK'],
'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'],
],
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.url', 'https://broken.example.com')
->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.1.url', 'https://healthy.example.com');
}); });
it('hides dns message text when dns status is ok', function () { it('hides dns message text when dns status is ok', function () {
@ -550,7 +669,7 @@
$message = $component->get('domainRows.0.dns_message'); $message = $component->get('domainRows.0.dns_message');
$recordType = dnsRecordTypeForIp($resolvedIp); $recordType = dnsRecordTypeForIp($resolvedIp);
// Failed checks show short "A record → ip" guidance; ok checks mention the hostname label. // Failed checks show required DNS record guidance; ok checks mention the hostname label.
if ($component->get('domainRows.0.dns_status') === 'failed') { if ($component->get('domainRows.0.dns_status') === 'failed') {
expect($message)->toBe("{$recordType} record → {$resolvedIp}") expect($message)->toBe("{$recordType} record → {$resolvedIp}")
->and($message)->not->toContain('CNAME'); ->and($message)->not->toContain('CNAME');
@ -574,7 +693,7 @@
->call('checkDomainDns', 0); ->call('checkDomainDns', 0);
expect($component->get('serverIp'))->toBe('2001:db8::10') expect($component->get('serverIp'))->toBe('2001:db8::10')
->and($component->get('domainRows.0.dns_message'))->toBe('AAAA record → 2001:db8::10'); ->and($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type AAAA pointing to 2001:db8::10');
}); });
it('uses short a-record guidance for compose applications', function () { it('uses short a-record guidance for compose applications', function () {
@ -596,7 +715,7 @@
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('checkDomainDns', 0); ->call('checkDomainDns', 0);
expect($component->get('domainRows.0.dns_message'))->toBe('A record → 172.16.0.3') expect($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type A pointing to 172.16.0.3')
->and($component->get('domainRows.0.dns_status'))->toBe('failed'); ->and($component->get('domainRows.0.dns_status'))->toBe('failed');
}); });
@ -608,7 +727,10 @@
$this->application->refresh(); $this->application->refresh();
expect($this->application->fqdn)->toBe(ValidationPatterns::normalizeApplicationDomains('HTTPS://App.Example.COM/Path')); expect(explode(',', (string) $this->application->fqdn))->toBe([
ValidationPatterns::normalizeApplicationDomains('HTTPS://App.Example.COM/Path'),
'https://www.app.example.com/Path',
]);
}); });
it('shows the missing www counterpart as a suggested domain row', function () { it('shows the missing www counterpart as a suggested domain row', function () {
@ -622,23 +744,28 @@
->assertSet('domainRows.0.is_suggested', false) ->assertSet('domainRows.0.is_suggested', false)
->assertSet('domainRows.1.url', 'https://www.example.com') ->assertSet('domainRows.1.url', 'https://www.example.com')
->assertSet('domainRows.1.is_suggested', true) ->assertSet('domainRows.1.is_suggested', true)
->assertSee('Suggested www') ->assertSet('domainRows.1.suggestion_label', null)
->assertSee('Add') ->assertSet('domainRows.1.dns_message', 'Not configured yet.')
->assertSee('Add domain')
->assertSee('Not configured yet.')
->assertDontSee('Not added ·')
->assertDontSee('click Add domain')
->assertDontSee('does not add this automatically')
->assertSee('https://www.example.com'); ->assertSee('https://www.example.com');
}); });
it('does not change suggested domain labels or persist until Set Direction saves', function () { it('does not change suggested domain role or persist until Set Direction saves', function () {
$this->application->update([ $this->application->update([
'fqdn' => 'https://example.com', 'fqdn' => 'https://example.com',
'redirect' => 'both', 'redirect' => 'both',
]); ]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.1.suggestion_label', 'Suggested www') ->assertSet('domainRows.1.suggestion_label', null)
->assertSet('domainRows.1.suggestion_role', 'pair') ->assertSet('domainRows.1.suggestion_role', 'pair')
->set('redirect', 'www') ->set('redirect', 'www')
// Dropdown alone must not rebuild suggestions or persist redirect. // Dropdown alone must not rebuild suggestions or persist redirect.
->assertSet('domainRows.1.suggestion_label', 'Suggested www') ->assertSet('domainRows.1.suggestion_label', null)
->assertSet('domainRows.1.suggestion_role', 'pair'); ->assertSet('domainRows.1.suggestion_role', 'pair');
expect($this->application->fresh()->redirect)->toBe('both'); expect($this->application->fresh()->redirect)->toBe('both');
@ -1138,20 +1265,20 @@
->and($webDomains)->toContain('https://www.web.example.com'); ->and($webDomains)->toContain('https://www.web.example.com');
}); });
it('uses compose service redirect for suggested domain messaging', function () { it('uses compose service redirect for suggested domain messaging when direction is both', function () {
$this->application->update([ $this->application->update([
'build_pack' => 'dockercompose', 'build_pack' => 'dockercompose',
'fqdn' => null, 'fqdn' => null,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
'docker_compose_domains' => json_encode([ 'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://web.example.com', 'redirect' => 'www'], 'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'],
]), ]),
]); ]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('isCompose', true) ->set('isCompose', true)
->set('composeServices', ['web']) ->set('composeServices', ['web'])
->set('serviceRedirects.web', 'www'); ->set('serviceRedirects.web', 'both');
$component->instance()->domainRows = (function () use ($component) { $component->instance()->domainRows = (function () use ($component) {
$method = new ReflectionMethod($component->instance(), 'buildDomainRows'); $method = new ReflectionMethod($component->instance(), 'buildDomainRows');
@ -1162,6 +1289,6 @@
$suggested = collect($component->get('domainRows'))->firstWhere('is_suggested', true); $suggested = collect($component->get('domainRows'))->firstWhere('is_suggested', true);
expect($suggested)->not->toBeNull() expect($suggested)->not->toBeNull()
->and($suggested['suggestion_role'] ?? null)->toBe('canonical') ->and($suggested['suggestion_role'] ?? null)->toBe('pair')
->and($suggested['url'] ?? null)->toBe('https://www.web.example.com'); ->and($suggested['url'] ?? null)->toBe('https://www.web.example.com');
}); });

View file

@ -0,0 +1,173 @@
<?php
use App\Livewire\Project\Application\Heading as ApplicationHeading;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->routeParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
];
});
/**
* Settings tab must carry both the active class and aria-current so CSS
* under .application-heading-actions can override the base tab resets.
*/
function assertSettingsTabActive(string $html): void
{
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(1);
// Desktop navbar CSS override must exist so active styles are visible.
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain(".application-heading-actions .app-tab[aria-current='page']")
->toContain('.application-heading-actions .app-tab.app-tab-active');
}
it('marks settings tab active on general configuration route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.configuration', $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
});
it('marks settings tab active on webhooks and other settings sub-routes', function (string $routeName) {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route($routeName, $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
})->with([
'webhooks' => 'project.application.webhooks',
'domains' => 'project.application.domains',
'advanced' => 'project.application.advanced',
'environment-variables' => 'project.application.environment-variables',
'danger' => 'project.application.danger',
]);
it('does not mark settings tab active on deployment logs', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.deployment.index', $this->routeParams))
->assertSuccessful()
->getContent();
expect($html)->toContain('Deployment Logs');
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(0);
// Deployment Logs should be the active primary tab instead.
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Deployment Logs\s*<\/a>/s',
$html
))->toBe(1);
});
it('syncs activeRouteName from the page route when heading is rendered on webhooks', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.webhooks', $this->routeParams))
->assertSuccessful()
->assertSeeLivewire(ApplicationHeading::class)
->getContent();
assertSettingsTabActive($html);
});
it('keeps activeRouteName when request is not an application page route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ApplicationHeading::class, ['application' => $this->application]);
$component->set('activeRouteName', 'project.application.webhooks');
$component->call('$refresh')
->assertSet('activeRouteName', 'project.application.webhooks');
});
it('uses app-tab-active utility for resource heading active styles', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)->toContain('@utility app-tab-active');
});

View file

@ -35,11 +35,16 @@
->and(collect($records)->pluck('type')->unique()->all())->toBe(['A']); ->and(collect($records)->pluck('type')->unique()->all())->toBe(['A']);
}); });
it('formats a copy-paste text block with all entries', function () { it('formats a BIND-compatible zone snippet for copy all', function () {
$text = DnsRecordHints::toCopyText([ $text = DnsRecordHints::toCopyText([
['type' => 'A', 'name' => 'app.example.com', 'value' => '203.0.113.10'], ['type' => 'A', 'name' => 'app.example.com', 'value' => '203.0.113.10'],
['type' => 'A', 'name' => 'www.example.com', 'value' => '203.0.113.10'], ['type' => 'A', 'name' => 'www.example.com', 'value' => '203.0.113.10'],
['type' => 'AAAA', 'name' => 'app.example.com', 'value' => '2001:db8::1'],
]); ]);
expect($text)->toBe("Type\tName\tValue\nA\tapp.example.com\t203.0.113.10\nA\twww.example.com\t203.0.113.10"); expect($text)->toBe(
"app.example.com. IN A 203.0.113.10\n".
"www.example.com. IN A 203.0.113.10\n".
"app.example.com. IN AAAA 2001:db8::1\n"
);
}); });

View file

@ -31,6 +31,17 @@
$this->actingAs($this->user); $this->actingAs($this->user);
}); });
it('hides preview scope for non-git applications', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerimage',
'git_repository' => null,
]);
Livewire::test(All::class, ['resource' => $application])
->assertSet('showPreview', false);
});
it('paginates managed environment variables without loading every row into the page collection', function () { it('paginates managed environment variables without loading every row into the page collection', function () {
$application = Application::factory()->create([ $application = Application::factory()->create([
'environment_id' => $this->environment->id, 'environment_id' => $this->environment->id,

View file

@ -0,0 +1,99 @@
<?php
/**
* Resource environment variables table: full names, Managed as a column,
* Type owns Production/Preview (no desktop Production badge in the name cell).
*/
test('resource environment variables table has a Managed column and no name-cell Production badge on desktop', function () {
$all = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
// Header includes Managed between Name and Type.
expect($all)
->toContain('$showEnvironmentType = $showPreview')
->toContain("toggleVariableFilter('{{ \$value }}')")
->toContain('toggleServiceFilter(@js($serviceName))')
->toContain('$this->serviceFilterOptions')
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
->toContain('wire:loading.flex wire:target="clearFilters"')
->toContain('wire:click="clearFilters"')
->toContain('Clear filters')
->toContain('max-h-80 overflow-y-auto p-1')
->toContain('min-w-44! overflow-hidden! p-0!')
->toContain('relative z-20 border-t')
->toContain('dark:bg-[#171717]')
->not->toContain("'all' => 'All variables'")
->toContain("setTableSort('{{ \$value }}')")
->toContain('Loading environment variables...')
->toContain('opacity-40 blur-[2px]')
->toContain('setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage')
->toContain("'buildtime' => 'Buildtime'")
->toContain("'runtime' => 'Runtime'")
->toContain("'multiline' => 'Multiline'")
->toContain("'literal' => 'Literal'")
->toContain('$activeFilterCount')
->toContain('$activeFilterText')
->toContain("'button max-w-80 min-w-0'")
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
->toContain('>Name</span>')
->toContain('>Managed</span>')
->toContain('>Type</span>');
// Name cell does not repeat the environment type; Type owns Production/Preview.
expect($show)
->toContain('env-managed-desktop')
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile')
->toContain('env-managed-desktop data-table-cell-check')
->toContain('$isMagicVariable');
// Production/Managed desktop badges must not sit bare in the name cell without mobile class.
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,400}<span class="table-badge shrink-0">Managed<\/span>/'
);
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,500}<span class="table-badge shrink-0">\{\{\s*\$rowScopeLabel\s*\}\}<\/span>/'
);
expect($hardcoded)
->toContain('env-managed-desktop data-table-cell-check')
->toContain('title="Environment variable details"')
->toContain('<x-forms.input label="Value" :value="$value ?? \'\'" readonly />')
->not->toContain("{{ filled(\$value) ? \$value : '(empty)' }}")
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile');
// Mobile-only badge classes beat .table-badge display on desktop.
expect($css)
->toContain('.env-managed-desktop')
// Resource grid is 9 columns (includes Managed).
->toContain('minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem');
expect($all)->not->toContain('<span>Comment</span>');
expect($show)->toContain('<x-helper :helper="e($comment)" />');
});
test('shared environment variables table still omits Managed column', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($editor)
->toContain('env-table-grid-shared')
->not->toContain('>Managed</span>');
// Shared rows skip the Managed column cell.
expect($show)->toContain('! $isSharedVariable');
});
test('managed environment variables are ordered first', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
expect($component)
->toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
->toMatch("/'kind' => 'hardcoded',[\\s\\S]+?'kind' => 'managed'/");
});

View file

@ -87,7 +87,7 @@
test('file mount modal shows the calculated host file path above the destination input', function () { test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application]) Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.') ->assertSeeText('Create a managed file on the host and mount it inside the container.')
->assertSeeText('Host file path') ->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/') ->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf') ->set('file_storage_path', '/etc/nginx/nginx.conf')
@ -136,7 +136,10 @@
->call('submitPersistentVolume') ->call('submitPersistentVolume')
->assertDispatched('success') ->assertDispatched('success')
->assertDispatched('refreshStorages') ->assertDispatched('refreshStorages')
->assertDispatched('configurationChanged'); ->assertDispatched('configurationChanged')
->assertSet('activeTab', 'volumes')
->assertSet('volumeCount', 1)
->assertSee($this->application->uuid.'-data');
}); });
test('volume storage list shows volumes added after it was mounted', function () { test('volume storage list shows volumes added after it was mounted', function () {
@ -159,10 +162,22 @@
$storageList $storageList
->assertDontSee($secondVolume->name) ->assertDontSee($secondVolume->name)
->dispatch('refreshStorages') ->call('refreshList')
->assertSee($secondVolume->name); ->assertSee($secondVolume->name);
}); });
test('adding a volume switches to the volumes tab immediately', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSet('activeTab', 'volumes')
->set('activeTab', 'directories')
->set('name', 'cache')
->set('mount_path', '/cache')
->call('submitPersistentVolume')
->assertSet('activeTab', 'volumes')
->assertSee($this->application->uuid.'-cache')
->assertDontSee('No directory mounts configured');
});
test('deleting a file mount refreshes the configuration warning', function () { test('deleting a file mount refreshes the configuration warning', function () {
$file = LocalFileVolume::create([ $file = LocalFileVolume::create([
'fs_path' => '/etc/nginx/nginx.conf', 'fs_path' => '/etc/nginx/nginx.conf',

View file

@ -0,0 +1,50 @@
<?php
test('default form buttons and inputs share the same control height', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)
->toContain('@utility input-select {')
->toContain('@utility button {');
preg_match('/@utility input-select \{[^}]*\}/s', $utilities, $inputSelect);
preg_match('/@utility button \{[^}]*\}/s', $utilities, $button);
expect($inputSelect[0] ?? '')
->toContain('h-9')
->and($button[0] ?? '')->toContain('h-9')
->and($button[0] ?? '')->toContain('min-h-9')
->and($button[0] ?? '')->toContain('whitespace-nowrap')
->and($button[0] ?? '')->toContain('shrink-0')
->and($button[0] ?? '')->not->toContain('h-8');
});
test('settings form surfaces keep input and button heights equal', function () {
$css = file_get_contents(resource_path('css/app.css'));
preg_match(
'/\.application-settings-workspace \.input,[\s\S]*?\.application-settings-form \.select \{[\s\S]*?\}/',
$css,
$inputs
);
preg_match(
'/\.application-settings-workspace \.button,[\s\S]*?\.application-settings-form \.button \{[\s\S]*?\}/',
$css,
$buttons
);
expect($inputs[0] ?? '')
->toContain('height: 2rem;')
->and($buttons[0] ?? '')->toContain('height: 2rem;')
->and($buttons[0] ?? '')->toContain('min-height: 2rem;')
->and($buttons[0] ?? '')->toContain('white-space: nowrap;');
});
test('directory storage actions wrap on narrow viewports instead of stacking uneven heights', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/file-storage.blade.php'));
expect($view)
->toContain('flex flex-wrap items-center gap-2')
->toContain('Convert to file')
->toContain('Configure Backup');
});

View file

@ -12,6 +12,7 @@
->toContain('aria-label="More information"') ->toContain('aria-label="More information"')
->toContain('info-helper-popup') ->toContain('info-helper-popup')
->toContain('name="info-circle"') ->toContain('name="info-circle"')
->toContain('class="size-3.5 text-neutral-400')
->not->toContain('<div x-ref="trigger" class="info-helper"'); ->not->toContain('<div x-ref="trigger" class="info-helper"');
}); });

View file

@ -44,6 +44,28 @@
->toContain(':title="current"'); ->toContain(':title="current"');
}); });
test('listbox shows an empty state when it has no options', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
$operations = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
expect($listbox)
->toContain("'emptyText' => 'No options available.'")
->toContain('x-show="options.length === 0"');
expect($operations)->toContain('No network destinations are available on this server.');
});
test('listbox forwards dynamic disabled state to its trigger', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
$operations = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
expect($listbox)->toContain("\$attributes->whereStartsWith('x-bind:disabled')");
expect($operations)
->toContain('x-bind:disabled="!selectedCloneServer"')
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
});
test('notification event multiselect truncates long selected summaries', function () { test('notification event multiselect truncates long selected summaries', function () {
$html = Blade::render(<<<'BLADE' $html = Blade::render(<<<'BLADE'
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[ <x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[

View file

@ -65,10 +65,13 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
$application->update(['build_command' => 'pnpm build']); $application->update(['build_command' => 'pnpm build']);
// Banner summary is always available; full change rows load on demand.
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('The latest configuration has not been applied') ->assertSee('The latest configuration has not been applied')
->assertSee('Build command') ->assertSee('A rebuild is required.')
->assertSee('A rebuild is required.'); ->assertDontSee('Build command')
->call('refreshConfigurationChanges')
->assertSee('Build command');
}); });
it('refreshes configuration changes when the event is received', function () { it('refreshes configuration changes when the event is received', function () {
@ -85,6 +88,7 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
->dispatch('configurationChanged') ->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true) ->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied') ->assertSee('The latest configuration has not been applied')
->call('refreshConfigurationChanges')
->assertSee('Build command'); ->assertSee('Build command');
}); });
@ -108,8 +112,9 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
->dispatch('configurationChanged') ->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true) ->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied') ->assertSee('The latest configuration has not been applied')
->assertSee('Directory mount') ->assertSee('Please redeploy to apply the new configuration.')
->assertSee('Please redeploy to apply the new configuration.'); ->call('refreshConfigurationChanges')
->assertSee('Directory mount');
}); });
it('refreshes stale modal configuration diff before opening changes', function () { it('refreshes stale modal configuration diff before opening changes', function () {
@ -119,6 +124,7 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
$application->update(['build_command' => 'pnpm build']); $application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('Build command') ->assertSee('Build command')
->assertDontSee('Start command'); ->assertDontSee('Start command');
@ -134,7 +140,29 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
->assertDontSee('Build command'); ->assertDontSee('Build command');
}); });
it('does not render environment variable secret values', function () { it('keeps full configuration change rows out of the initial Livewire snapshot', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
expect($component->get('isConfigurationChanged'))->toBeTrue()
->and($component->get('configurationDiff'))->toHaveKeys(['count', 'requires_build'])
->and($component->get('configurationDiff'))->not->toHaveKey('changes');
$component->call('refreshConfigurationChanges');
expect($component->get('configurationDiff'))->toHaveKey('changes')
->and(data_get($component->get('configurationDiff'), 'changes'))->not->toBeEmpty();
});
it('redacts unlocked environment values for team members in the change list', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$application = configurationCheckerApplication($this->environment); $application = configurationCheckerApplication($this->environment);
EnvironmentVariable::create([ EnvironmentVariable::create([
'key' => 'API_TOKEN', 'key' => 'API_TOKEN',
@ -149,15 +177,26 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']); $application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN') ->call('refreshConfigurationChanges');
->assertSee('••••••••')
->assertDontSee('Hidden') $envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->assertDontSee('old-secret') ->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
->assertDontSee('new-secret'); || str_contains((string) data_get($change, 'label'), 'API_TOKEN'));
expect($envChange)->not->toBeNull()
->and(data_get($envChange, 'old_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'new_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'old_full_value'))->toBeNull()
->and(data_get($envChange, 'new_full_value'))->toBeNull();
}); });
it('renders added environment variables as set without exposing secret values', function () { it('redacts newly added environment values for team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$application = configurationCheckerApplication($this->environment); $application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application); markConfigurationCheckerApplicationDeployed($application);
@ -171,12 +210,18 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
'resourceable_id' => $application->id, 'resourceable_id' => $application->id,
]); ]);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('API_TOKEN') ->assertSee('API_TOKEN')
->assertSee('From') ->assertSee('Current')
->assertSee('-') ->assertSee('New');
->assertSee('To')
->assertSee('••••••••') $envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->assertDontSee('Hidden') ->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
->assertDontSee('new-secret'); || str_contains((string) data_get($change, 'label'), 'API_TOKEN'));
expect($envChange)->not->toBeNull()
->and(data_get($envChange, 'old_display_value'))->toBe('-')
->and(data_get($envChange, 'new_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'type'))->toBe('added');
}); });

View file

@ -0,0 +1,106 @@
<?php
use App\Livewire\Project\Application\Advanced;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createApplicationForContainerNamingTest(): Application
{
$team = Team::factory()->create();
$team->members()->attach(auth()->id(), ['role' => 'owner']);
session(['currentTeam' => $team]);
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Application::create([
'name' => 'container-naming-test-app',
'git_repository' => 'https://github.com/coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
]);
}
beforeEach(function () {
$this->actingAs(User::factory()->create());
});
it('saves consistent container naming when container labels are managed manually', function () {
$application = createApplicationForContainerNamingTest();
// Renders the proxy empty-state that builds a configuration route.
$application->settings->update([
'is_container_label_readonly_enabled' => false,
]);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->set('isConsistentContainerNameEnabled', true)
->call('instantSave')
->assertSuccessful()
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Go to Container labels');
expect($application->settings()->first()->is_consistent_container_name_enabled)->toBeTrue();
});
it('renders the configuration link from application uuids when labels are manual', function () {
$application = createApplicationForContainerNamingTest();
$application->settings->update([
'is_container_label_readonly_enabled' => false,
]);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
$expectedHref = route('project.application.configuration', [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
]).'#container-labels-section';
Livewire::test(Advanced::class, ['application' => $application])
->assertSuccessful()
->assertSee($expectedHref, false);
});
it('toggles consistent naming when labels are managed by coolify', function () {
$application = createApplicationForContainerNamingTest();
$application->settings->update([
'is_container_label_readonly_enabled' => true,
]);
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->set('isConsistentContainerNameEnabled', true)
->call('instantSave')
->assertSuccessful()
->assertHasNoErrors()
->assertDispatched('success');
expect($application->settings()->first()->is_consistent_container_name_enabled)->toBeTrue();
});
it('only shows the custom container name for consistent naming', function () {
$application = createApplicationForContainerNamingTest();
$application = $application->fresh(['environment.project', 'settings', 'destination']);
Livewire::test(Advanced::class, ['application' => $application])
->assertDontSee('Custom container name')
->set('isConsistentContainerNameEnabled', true)
->assertSee('Custom container name');
});

View file

@ -0,0 +1,26 @@
<?php
/**
* Active sidebar/nav items use a solid fill only no left accent rail
* (::before) and no gradient wash.
*/
test('active menu items do not render an accent rail', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
preg_match('/@utility menu-item-active \{[^}]*\}/s', $utilities, $menuItemActive);
preg_match('/@utility menu-subitem-active \{[^}]*\}/s', $utilities, $menuSubitemActive);
expect($menuItemActive[0] ?? '')
->toContain('rounded-md')
->toContain('bg-black/[0.05]')
->and($menuSubitemActive[0] ?? '')
->toContain('rounded-md')
->toContain('bg-black/[0.05]');
// Accent rail must be disabled (content: none), not drawn as a 3px accent bar.
expect($appCss)
->toMatch('/\.menu-item-active::before,\s*\.menu-subitem-active::before\s*\{[^}]*content:\s*none/s')
->not->toMatch('/\.menu-item-active::before\s*\{[^}]*width:\s*3px/s')
->not->toMatch('/\.menu-item-active::before\s*\{[^}]*background:\s*var\(--color-accent\)/s');
});

View file

@ -0,0 +1,30 @@
<?php
/**
* Inactive sidebar/nav menu items previously used dark:text-fg-faint (#6e6e74)
* on near-black app chrome (~3.9:1), below WCAG AA for normal text. Defaults
* should use fg-dim / neutral-600 instead.
*/
test('inactive menu items use accessible contrast tokens', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
preg_match('/@utility menu-item \{[^}]*\}/s', $utilities, $menuItem);
preg_match('/@utility menu-subitem \{[^}]*\}/s', $utilities, $menuSubitem);
preg_match('/@utility sub-menu-item \{[^}]*\}/s', $utilities, $subMenuItem);
preg_match('/@utility nav-section \{[^}]*\}/s', $utilities, $navSection);
expect($menuItem[0] ?? '')
->toContain('dark:text-fg-dim')
->toContain('text-neutral-600')
->not->toContain('dark:text-fg-faint')
->and($menuSubitem[0] ?? '')
->toContain('dark:text-fg-dim')
->toContain('text-neutral-600')
->not->toContain('dark:text-fg-faint')
->and($subMenuItem[0] ?? '')
->toContain('dark:text-fg-dim')
->toContain('text-neutral-600')
->and($navSection[0] ?? '')
->toContain('dark:text-fg-dim')
->not->toContain('dark:text-fg-faint');
});

View file

@ -0,0 +1,189 @@
<?php
use App\Livewire\Project\Application\Backup\Create;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Process::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['id' => 0, 'is_dns_validation_enabled' => false]
));
});
/**
* @return array{0: Application, 1: LocalPersistentVolume, 2: Team}
*/
function createPerfApplicationWithVolumes(int $volumeCount = 5): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
test()->actingAs($user);
session(['currentTeam' => $team]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'ip' => '203.0.113.10',
]);
$server->settings()->update([
'is_reachable' => false,
'is_usable' => false,
]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'nixpacks',
]);
$application->setRelation('environment', $environment);
$environment->setRelation('project', $project);
$firstVolume = null;
for ($i = 0; $i < $volumeCount; $i++) {
$volume = LocalPersistentVolume::create([
'name' => $application->uuid.'-vol-'.$i,
'mount_path' => '/data/'.$i,
'host_path' => null,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
'is_preview_suffix_enabled' => true,
]);
$firstVolume ??= $volume;
}
LocalFileVolume::create([
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/config.env',
'mount_path' => '/app/config.env',
'content' => str_repeat('x', 5000),
'is_directory' => false,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$application = $application->fresh(['environment.project', 'destination.server', 'persistentStorages']);
return [$application, $firstVolume, $team];
}
it('renders volume rows without nesting Livewire Show components', function () {
[$application] = createPerfApplicationWithVolumes(5);
$html = Livewire::test(All::class, ['resource' => $application])->html();
expect($html)
->toContain('data-table')
->toContain('openBackupModal')
->toContain('wire:submit="submit(')
->not->toContain('livewire:project.shared.storages.show')
->not->toContain('shared-configure-volume-backup-');
});
it('batches volume backup meta and exposes forms for every volume', function () {
[$application, , $team] = createPerfApplicationWithVolumes(5);
foreach ($application->persistentStorages as $storage) {
$storage->scheduledBackups()->create([
'team_id' => $team->id,
'frequency' => 'daily',
'enabled' => true,
]);
}
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('volumeBackupMeta'))->toHaveCount(5)
->and($component->get('forms'))->toHaveCount(5);
foreach ($component->get('volumeBackupMeta') as $meta) {
expect($meta['enabled'])->toBeTrue()
->and($meta['url'])->not->toBeNull();
}
});
it('updates a volume row from the parent All component', function () {
[$application, $volume] = createPerfApplicationWithVolumes(2);
Livewire::test(All::class, ['resource' => $application])
->set("forms.{$volume->id}.mountPath", '/data/updated')
->call('submit', $volume->id)
->assertDispatched('success');
expect($volume->fresh()->mount_path)->toBe('/data/updated');
});
it('mounts a single shared backup modal only after openBackupModal', function () {
[$application, $volume] = createPerfApplicationWithVolumes(3);
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->html())
->toContain('openBackupModal')
->not->toContain('shared-configure-volume-backup-')
->and($component->get('backupModalStorageId'))->toBeNull();
$component
->call('openBackupModal', $volume->id)
->assertSet('backupModalStorageId', $volume->id)
->assertSee('Frequency');
});
it('keeps file mount content out of the volumes tab snapshot', function () {
[$application] = createPerfApplicationWithVolumes(2);
$component = Livewire::test(Storage::class, ['resource' => $application]);
expect($component->get('activeTab'))->toBe('volumes')
->and($component->get('fileCount'))->toBe(1)
->and($component->get('volumeCount'))->toBe(2)
->and(collect($component->get('fileStorage')))->toHaveCount(0);
$component->call('setActiveTab', 'files')
->assertSet('activeTab', 'files');
expect(collect($component->get('fileStorage')))->toHaveCount(1);
});
it('loads only the locked target when opening backup create from a volume row', function () {
[$application, $volume] = createPerfApplicationWithVolumes(4);
DB::flushQueryLog();
DB::enableQueryLog();
$component = Livewire::test(Create::class, [
'application' => $application,
'selectedTargetKey' => 'volume:'.$volume->id,
]);
$queryCount = count(DB::getQueryLog());
DB::disableQueryLog();
expect($component->get('targetLocked'))->toBeTrue()
->and($component->get('targets'))->toHaveCount(1)
->and($queryCount)->toBeLessThan(15);
});

View file

@ -0,0 +1,203 @@
<?php
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['id' => 0, 'is_dns_validation_enabled' => false]
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createApplicationWithVolume(array $applicationAttributes = [], array $volumeAttributes = []): array
{
$application = Application::factory()->create(array_merge([
'uuid' => (string) Str::uuid(),
'name' => 'Storage App',
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'nixpacks',
], $applicationAttributes));
$volume = LocalPersistentVolume::create(array_merge([
'uuid' => (string) Str::uuid(),
'name' => $application->uuid.'-data',
'mount_path' => '/data',
'host_path' => null,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
'is_preview_suffix_enabled' => true,
], $volumeAttributes));
return [$application, $volume];
}
it('renders volumes as a data table with shared column headers', function () {
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
$showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
expect($allView)
->toContain('data-table')
->toContain('data-table-header')
->toContain('volumes-table-grid')
->toContain('volumes-table-grid-readonly')
->toContain('Volume Name')
->toContain('Source Path')
->toContain('Destination Path')
->toContain('supportsPreviewSuffix')
->toContain('openBackupModal')
->toContain('data-table-row')
->toContain('volumes-mobile-label')
->toContain('table-badge-success')
->not->toContain('livewire:project.shared.storages.show')
->not->toContain('x-status-badge');
// Show remains available for isolated embeds/tests but is no longer nested from All.
expect($showView)
->toContain('data-table-row')
->toContain('volumes-table-grid');
// Service stack page: one settings-section card per compose service/resource.
expect($storageView)
->toContain('Str::headline($resource->name)')
->toContain(':flush="true"')
->toContain('storage-service-');
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.volumes-table-grid')
->toContain('.volumes-table-grid-with-pr')
->toContain('.volumes-table-grid-readonly')
->toContain('.volumes-mobile-label')
->toContain('font-size: 13px') // same as .application-settings-form label
->toContain('@media (max-width: 768px)')
->toContain('.table-badge-success');
// Settings form labels are 13px (not Tailwind text-sm 14px).
expect($css)
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
});
it('shows PR deployment suffix only for git-based applications', function () {
[$gitApp] = createApplicationWithVolume(['build_pack' => 'nixpacks']);
Livewire::test(All::class, ['resource' => $gitApp])
->assertSet('supportsPreviewSuffix', true)
->assertSee('Add suffix');
[$dockerImageApp] = createApplicationWithVolume([
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'latest',
]);
Livewire::test(All::class, ['resource' => $dockerImageApp])
->assertSet('supportsPreviewSuffix', false)
->assertDontSee('Add suffix')
->assertDontSee('PR deployment suffix');
});
it('hides PR deployment suffix for databases', function () {
$database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'pg-test',
'postgres_password' => 'secret',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
LocalPersistentVolume::create([
'uuid' => (string) Str::uuid(),
'name' => $database->uuid.'-data',
'mount_path' => '/var/lib/postgresql/data',
'host_path' => null,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
'is_preview_suffix_enabled' => true,
]);
Livewire::test(All::class, ['resource' => $database])
->assertSet('supportsPreviewSuffix', false)
->assertDontSee('Add suffix')
->assertDontSee('PR deployment suffix');
});
it('uses a compact table badge for enabled backups instead of status-badge', function () {
$showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
expect($showView)
->toContain('table-badge-success')
->toContain('Volume backup is enabled')
->not->toContain('x-status-badge')
->not->toContain('status="Backup enabled"');
// Badge label is the short "Backup" text, not the old pill-with-label that broke the input row.
expect(preg_match('/table-badge-success[^>]*>\s*Backup\s*</', $showView))->toBeGreaterThan(0);
});
it('gates file storage PR suffix markup behind git_based applications', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/file-storage.blade.php'));
expect($view)
->toContain('$resource->git_based()')
->toContain('PR deployment suffix');
});

View file

@ -0,0 +1,11 @@
<?php
test('resource table subtitle shows description only and never falls back to uuid', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($view)
->toContain('x-show="item.description"')
->toContain('x-text="item.description"')
->not->toContain('item.description || item.fqdn || item.uuid')
->not->toContain('item.fqdn || item.uuid');
});

View file

@ -0,0 +1,119 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::create([
'name' => 'default',
'network' => 'coolify',
'server_id' => $this->server->id,
]);
$this->project = Project::factory()->create([
'team_id' => $this->team->id,
'name' => 'coolLabs',
]);
$this->environment = $this->project->environments()->firstOrFail();
});
/**
* @js() embeds JSON with unicode-escaped quotes (\u0022). Match that encoding.
*/
function assertJsPayloadContains(string $html, string $needle): void
{
$escaped = str_replace('"', '\u0022', $needle);
expect($html)->toContain($escaped);
}
function assertJsPayloadDoesNotContain(string $html, string $needle): void
{
$escaped = str_replace('"', '\u0022', $needle);
expect($html)->not->toContain($escaped);
}
test('resource index type labels use category names not engine names for databases', function () {
$mysql = StandaloneMysql::create([
'name' => 'mysql-database-uprlcxnoukmrxge65gdrgwqm',
'mysql_root_password' => 'password',
'mysql_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'exited:unhealthy',
]);
StandalonePostgresql::create([
'name' => 'postgresql-database-test',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'exited:unhealthy',
]);
Application::create([
'name' => 'docker-image-test',
'fqdn' => 'https://example.com',
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'dockerimage',
'ports_exposes' => '80',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Service::create([
'name' => 'actualbudget-test',
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => "services:\n app:\n image: nginx\n",
'docker_compose' => "services:\n app:\n image: nginx\n",
'service_type' => 'actualbudget',
]);
$response = $this->get(route('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]));
$response->assertSuccessful();
$html = $response->getContent();
// Category labels match Application / Service — not engine-specific names.
assertJsPayloadContains($html, '"type":"database"');
assertJsPayloadContains($html, '"typeLabel":"Database"');
assertJsPayloadContains($html, '"type":"application"');
assertJsPayloadContains($html, '"typeLabel":"Application"');
assertJsPayloadContains($html, '"type":"service"');
assertJsPayloadContains($html, '"typeLabel":"Service"');
assertJsPayloadDoesNotContain($html, '"typeLabel":"MySQL"');
assertJsPayloadDoesNotContain($html, '"typeLabel":"PostgreSQL"');
expect($html)->toContain($mysql->name);
});

View file

@ -0,0 +1,16 @@
<?php
/**
* Docker CPU constraints docs link belongs in the CPU section header as a button.
*/
test('resource limits cpu docs link is a header action button', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/resource-limits.blade.php'));
expect($view)
->toContain('<x-slot:actions>')
->toContain('Docker CPU constraints')
->toContain('https://docs.docker.com/engine/containers/resource_constraints/#cpu')
->toContain('class="button"')
->toContain('name="external-link"')
->not->toContain('mt-4 inline-flex items-center gap-1 text-xs text-neutral-500');
});

View file

@ -177,6 +177,21 @@
->toContain('https://api.example.com'); ->toContain('https://api.example.com');
}); });
it('auto-adds the suggested www pair when adding a service domain with both directions', function () {
$this->webApp->update(['redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomain', 'https://web.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com');
});
it('adds a domain to a selected service application', function () { it('adds a domain to a selected service application', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id) ->set('newServiceApplicationId', $this->webApp->id)
@ -186,7 +201,8 @@
->assertDispatched('success'); ->assertDispatched('success');
$this->webApp->refresh(); $this->webApp->refresh();
expect($this->webApp->fqdn)->toBe('https://web.example.com'); expect(explode(',', (string) $this->webApp->fqdn))
->toBe(['https://web.example.com', 'https://www.web.example.com']);
}); });
it('rolls back a domain change when compose regeneration fails', function () { it('rolls back a domain change when compose regeneration fails', function () {
@ -282,8 +298,10 @@
$this->apiApp->refresh(); $this->apiApp->refresh();
expect($this->apiApp->fqdn)->toBe('https://api.example.com') expect(explode(',', (string) $this->apiApp->fqdn))
->and($this->apiApp->domain_dns_statuses)->toBeNull(); ->toBe(['https://api.example.com', 'https://www.api.example.com'])
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['status'] ?? null)->toBe('skipped')
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
}); });
it('saves after confirming both a domain conflict and a missing required port', function () { it('saves after confirming both a domain conflict and a missing required port', function () {
@ -317,7 +335,8 @@
->assertSet('pendingAction', null) ->assertSet('pendingAction', null)
->assertDispatched('success'); ->assertDispatched('success');
expect($this->webApp->fresh()->fqdn)->toBe('https://api.example.com'); expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com']);
}); });
it('loads persisted dns status for service applications', function () { it('loads persisted dns status for service applications', function () {
@ -333,7 +352,30 @@
]); ]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('DNS mismatch stored.'); ->assertSee('DNS mismatch')
->assertDontSee('DNS mismatch stored.')
->call('openDnsRecordsModal')
->assertSet('showDnsRecordsModal', true);
});
it('shows service dns mismatches before other domain entries', function () {
$this->webApp->update([
'fqdn' => 'https://healthy.example.com',
'domain_dns_statuses' => [
'https://healthy.example.com' => ['status' => 'ok', 'message' => 'OK'],
],
]);
$this->apiApp->update([
'fqdn' => 'https://broken.example.com',
'domain_dns_statuses' => [
'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'],
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.url', 'https://broken.example.com')
->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.2.url', 'https://healthy.example.com');
}); });
it('hides dns message text when service domain dns status is ok', function () { it('hides dns message text when service domain dns status is ok', function () {

View file

@ -0,0 +1,46 @@
<?php
/**
* Settings nav sub-items scroll a section into view and flash its border
* for 500ms so the user can see which card was targeted.
*/
test('settings section highlight animation is defined for 500ms', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
expect($appCss)
->toContain('@keyframes application-settings-section-highlight')
->toContain('.application-settings-section.is-section-highlight')
->toContain('.application-settings-section.is-section-highlight::after')
->toContain('animation: application-settings-section-highlight 500ms ease-out forwards')
->toContain('border: 0.5px solid var(--color-accent)')
->toContain('var(--color-accent)');
});
test('settings navigation leaves room for the default tab focus ring', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
preg_match('/\.application-settings-navigation\s*\{[^}]*\}/s', $appCss, $nav);
// Tab focus keeps the global ring-2 + ring-offset-2; do not thin it.
expect($appCss)
->not->toContain('.menu-item:focus-visible')
->not->toContain('box-shadow: inset 0 0 0 0.5px var(--color-accent)')
->and($nav[0] ?? '')
->toContain('padding-right: 0.375rem');
});
test('configuration sidebar subitems trigger section highlight on scroll', function () {
$blade = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$appJs = file_get_contents(resource_path('js/app.js'));
expect($blade)
->toContain('scrollToSection(id)')
->toContain('window.scrollToSettingsSection?.(id)')
->toContain("scrollToSection('{{ \$section['id'] }}')")
->and($appJs)
->toContain('window.scrollToSettingsSection')
->toContain("el.classList.add('is-section-highlight')")
->toContain("behavior: 'smooth'")
->toContain("addEventListener('scrollend'")
->toContain('stableFrames');
});

View file

@ -366,14 +366,16 @@
'resource' => $application, 'resource' => $application,
]) ])
->set('isReadOnly', true) ->set('isReadOnly', true)
->assertSee('Configure Backup') ->assertSee('Backup')
->assertDontSee('Backups made while the application is writing'); ->assertDontSee('Backups made while the application is writing');
$html = $component->html(); $html = $component->html();
expect(strpos($html, 'Configure Backup')) // Read-only volume rows are table cells (no form); backup action still renders in the row.
->toBeGreaterThan(strpos($html, '<form')) expect($html)
->toBeLessThan(strpos($html, '</form>')); ->toContain('Configure Volume Backup')
->toContain('data-table-row')
->toContain('Backup');
}); });
it('only shows the backup enabled badge for an enabled volume backup', function () { it('only shows the backup enabled badge for an enabled volume backup', function () {
@ -391,7 +393,7 @@
$component = Livewire::test(Show::class, [ $component = Livewire::test(Show::class, [
'storage' => $volume, 'storage' => $volume,
'resource' => $application, 'resource' => $application,
])->assertDontSee('Backup enabled'); ])->assertDontSee('table-badge-success', false);
$backup->update(['enabled' => true]); $backup->update(['enabled' => true]);
@ -404,14 +406,17 @@
$component $component
->dispatch('refreshVolumeBackups') ->dispatch('refreshVolumeBackups')
->assertSeeInOrder(['Volume Name', 'Backup enabled']) ->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false); ->assertSee('href="'.$backupUrl.'"', false);
Livewire::test(Show::class, [ Livewire::test(Show::class, [
'storage' => $volume, 'storage' => $volume,
'resource' => $application, 'resource' => $application,
'isFirst' => false, 'isFirst' => false,
])->assertSeeInOrder(['Volume Name', 'Backup enabled']); ])
->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled');
}); });
it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () { it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () {
@ -441,7 +446,8 @@
'storage' => $volume, 'storage' => $volume,
'resource' => $application, 'resource' => $application,
]) ])
->assertSee('Backup enabled') ->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false); ->assertSee('href="'.$backupUrl.'"', false);
}); });

View file

@ -20,7 +20,7 @@
it('builds a short A-record guidance message for ipv4 targets', function () { it('builds a short A-record guidance message for ipv4 targets', function () {
$message = dnsMismatchGuidanceMessage('172.16.0.3 (coolify-testing-host)', '172.16.0.3'); $message = dnsMismatchGuidanceMessage('172.16.0.3 (coolify-testing-host)', '172.16.0.3');
expect($message)->toBe('A record → 172.16.0.3') expect($message)->toBe('Required DNS record type A pointing to 172.16.0.3')
->and($message)->not->toContain('—') ->and($message)->not->toContain('—')
->and($message)->not->toContain('continue'); ->and($message)->not->toContain('continue');
}); });
@ -28,14 +28,14 @@
it('builds a short AAAA-record guidance message for ipv6 targets', function () { it('builds a short AAAA-record guidance message for ipv6 targets', function () {
$message = dnsMismatchGuidanceMessage('2001:db8::1', '2001:db8::1'); $message = dnsMismatchGuidanceMessage('2001:db8::1', '2001:db8::1');
expect($message)->toBe('AAAA record → 2001:db8::1') expect($message)->toBe('Required DNS record type AAAA pointing to 2001:db8::1')
->and($message)->not->toContain('—') ->and($message)->not->toContain('—')
->and($message)->not->toContain('continue'); ->and($message)->not->toContain('continue');
}); });
it('prefers the bare ip over a hostname label', function () { it('prefers the bare ip over a hostname label', function () {
expect(dnsMismatchGuidanceMessage('coolify-testing-host', '172.16.0.3')) expect(dnsMismatchGuidanceMessage('coolify-testing-host', '172.16.0.3'))
->toBe('A record → 172.16.0.3'); ->toBe('Required DNS record type A pointing to 172.16.0.3');
}); });
it('falls back when no target is available', function () { it('falls back when no target is available', function () {