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
@@ -38,6 +38,32 @@ class Create extends Component
public function mount(): void
{
$this->authorize('view', $this->application);
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey;
// When opened from a volume row the target is fixed — skip listing every volume/directory.
if ($this->targetLocked && is_string($this->selectedTargetKey)) {
$target = $this->selectedTarget();
if ($target instanceof LocalPersistentVolume) {
$this->targets = collect([[
'key' => 'volume:'.$target->id,
'type' => 'Volume',
'name' => $target->name,
]]);
} elseif ($target instanceof LocalFileVolume) {
$this->targets = collect([[
'key' => 'directory:'.$target->id,
'type' => 'Directory',
'name' => $target->fs_path,
]]);
} else {
$this->targets = collect();
}
$this->loadSelectedBackup();
return;
}
$volumes = $this->application->persistentStorages()
->orderBy('name')
->get()
@@ -57,7 +83,6 @@ class Create extends Component
'name' => $directory->fs_path,
]);
$this->targets = $volumes->concat($directories)->values();
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup();
}
@@ -24,7 +24,7 @@ class Configuration extends Component
public function mount()
{
$this->currentRoute = request()->route()->getName();
$this->syncCurrentRoute();
$project = currentTeam()
->projects()
@@ -36,10 +36,14 @@ class Configuration extends Component
->where('uuid', request()->route('environment_uuid'))
->firstOrFail();
$application = $environment->applications()
->with(['destination'])
->with(['destination.server', 'environment.project'])
->where('uuid', request()->route('application_uuid'))
->firstOrFail();
// Parent page already resolved these; keep them on the model for nested components.
$application->setRelation('environment', $environment);
$environment->setRelation('project', $project);
$this->project = $project;
$this->environment = $environment;
$this->application = $application;
@@ -49,8 +53,23 @@ class Configuration extends Component
}
}
/**
* Keep sidebar active state in sync on full-page navigations.
* Ignore Livewire update requests so poll/refresh does not clear it.
*/
protected function syncCurrentRoute(): void
{
$routeName = request()->route()?->getName();
if (is_string($routeName) && str_starts_with($routeName, 'project.application.')) {
$this->currentRoute = $routeName;
}
}
public function render()
{
$this->syncCurrentRoute();
return view('livewire.project.application.configuration');
}
}
+132 -67
View File
@@ -200,6 +200,10 @@ class Domains extends Component
}
}
// 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();
}
@@ -269,14 +273,26 @@ class Domains extends Component
}
}
return $rows;
return $this->sortDomainRowsByDnsStatus($rows);
}
foreach ($this->splitDomains($this->application->fqdn) as $url) {
$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 @@ class Domains extends Component
$base['is_suggested'] = true;
$base['suggested_for'] = $url;
$base['suggestion_label'] = $meta['label'];
$base['suggestion_label'] = null;
$base['suggestion_role'] = $meta['role'];
$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'];
} 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']);
}
}
$base['dns_message'] = $meta['pending_message'];
$suggested[] = $base;
}
@@ -354,41 +361,40 @@ class Domains extends Component
*/
protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array
{
$pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp);
$pendingMessage = 'Not configured yet.';
$redirect = $redirectOverride ?? ($this->redirect ?: 'both');
return match ($redirect) {
'www' => $suggestedIsWww
? [
'label' => 'Canonical www',
'label' => 'Not added · canonical www',
'role' => 'canonical',
'pending_message' => "Required as the redirect target (www). {$pointDns}",
'dns_suffix' => 'This is the canonical www host traffic should land on.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
]
: [
'label' => 'Redirect source',
'label' => 'Not added · redirect source',
'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}",
'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
'non-www' => $suggestedIsWww
? [
'label' => 'Redirect source',
'label' => 'Not added · redirect source',
'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}",
'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.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
]
: [
'label' => 'Canonical non-www',
'label' => 'Not added · canonical non-www',
'role' => 'canonical',
'pending_message' => "Required as the redirect target (non-www). {$pointDns}",
'dns_suffix' => 'This is the canonical non-www host traffic should land on.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
default => [
'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www',
'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www',
'role' => 'pair',
'pending_message' => "Also add this host so both www and non-www work. {$pointDns}",
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
};
@@ -546,7 +552,7 @@ class Domains extends Component
$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) {
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$serviceName = $this->domainRows[$index]['service'] ?? null;
@@ -554,10 +560,8 @@ class Domains extends Component
$isWww,
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
);
if ($meta['dns_suffix'] !== '') {
$this->domainRows[$index]['dns_message'] = trim($this->domainRows[$index]['dns_message'].' '.$meta['dns_suffix']);
}
$this->domainRows[$index]['suggestion_label'] = $meta['label'];
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
}
@@ -727,6 +731,11 @@ class Domains extends Component
}
$newUrls = $this->splitDomains($normalized);
$pairedUrls = collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all();
$current = $this->currentDomainList($this->newDomainService);
foreach ($newUrls as $url) {
@@ -747,10 +756,9 @@ class Domains extends Component
}
}
$merged = $current->merge($newUrls)->unique()->values();
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add';
// DNS was already validated (or overridden) in the modal; skip save-time toast noise.
if (! $this->saveDomainList($merged, $this->newDomainService, checkDns: false)) {
if (! $this->saveDomainList($merged, $this->newDomainService)) {
return;
}
@@ -761,7 +769,7 @@ class Domains extends Component
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains();
$this->checkUrlsDns($newUrls, $serviceForCheck);
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck);
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -931,7 +939,6 @@ class Domains extends Component
$this->forceAddSuggestedIndex = $index;
$this->editingIndex = $index;
$this->persistDomainDnsStatuses();
$this->dispatch('error', 'DNS validation failed.', $dnsFailure);
return;
}
@@ -940,7 +947,7 @@ class Domains extends Component
$merged = $current->merge($newUrls)->unique()->values();
$this->pendingAction = 'suggested';
$this->editingIndex = $index;
if (! $this->saveDomainList($merged, $serviceName, checkDns: false)) {
if (! $this->saveDomainList($merged, $serviceName)) {
return;
}
@@ -1017,6 +1024,7 @@ class Domains extends Component
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
$this->showEditDomainModal = true;
return;
}
@@ -1024,7 +1032,7 @@ class Domains extends Component
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainList($updated, $service, checkDns: false)) {
if (! $this->saveDomainList($updated, $service)) {
return;
}
@@ -1058,7 +1066,7 @@ class Domains extends Component
$service = $this->domainRows[$index]['service'];
$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;
}
@@ -1104,26 +1112,32 @@ class Domains extends Component
$current = $this->currentDomainList($serviceName);
$merged = $current->push($domain)->unique()->values();
if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false, checkDns: false)) {
if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false)) {
return;
}
$pairedUrls = $this->syncRedirectDomainPairs($serviceName);
$this->resetAddDomainForm();
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain generated.');
$this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge([$domain], $pairedUrls))), $serviceName);
return;
}
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
$this->application->fqdn = $fqdn;
$this->application->save();
$this->resetDefaultLabels();
$merged = $this->currentDomainList()->push($fqdn)->unique()->values();
if (! $this->saveDomainList($merged, null, checkConflicts: false)) {
return;
}
$pairedUrls = $this->syncRedirectDomainPairs(null);
$this->resetAddDomainForm();
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain generated.');
$this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge([$fqdn], $pairedUrls))));
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -1298,6 +1312,74 @@ class Domains extends Component
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).
*
@@ -1346,10 +1428,13 @@ class Domains extends Component
$this->pendingRedirectService = $serviceName;
// 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;
}
$this->pendingAction = null;
$this->pendingRedirectService = null;
return true;
}
@@ -1446,7 +1531,6 @@ class Domains extends Component
Collection $domains,
?string $serviceName = null,
bool $checkConflicts = true,
bool $checkDns = true,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@@ -1488,25 +1572,6 @@ class Domains extends Component
$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) {
$result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) {
+17 -1
View File
@@ -40,7 +40,7 @@ class Heading extends Component
public function mount()
{
$this->activeRouteName = request()->route()?->getName() ?? '';
$this->syncActiveRouteName();
$this->parameters = [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
@@ -51,6 +51,20 @@ class Heading extends Component
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit'));
}
/**
* Keep the active tab in sync with the real page route.
* Only update when the request is a full page route (not livewire.update),
* so wire:poll re-renders do not wipe the highlighted tab.
*/
protected function syncActiveRouteName(): void
{
$routeName = request()->route()?->getName();
if (is_string($routeName) && str_starts_with($routeName, 'project.application.')) {
$this->activeRouteName = $routeName;
}
}
public function checkStatus()
{
if ($this->application->destination->server->isFunctional()) {
@@ -188,6 +202,8 @@ class Heading extends Component
public function render()
{
$this->syncActiveRouteName();
return view('livewire.project.application.heading', [
'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
+8 -8
View File
@@ -189,14 +189,14 @@ class Index extends Component
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'),
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'),
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'),
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'Database'),
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Database'),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'Database'),
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'Database'),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'Database'),
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'Database'),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Database'),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'Database'),
'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'),
]);
}
+97 -53
View File
@@ -158,6 +158,10 @@ class Domains extends Component
$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();
}
@@ -197,7 +201,10 @@ class Domains extends Component
}
}
return $rows;
return collect($rows)
->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1)
->values()
->all();
}
/**
@@ -282,17 +289,10 @@ class Domains extends Component
$base['is_suggested'] = true;
$base['suggested_for'] = $url;
$base['suggestion_label'] = $meta['label'];
$base['suggestion_label'] = null;
$base['suggestion_role'] = $meta['role'];
$base['needs_force_add'] = false;
if (($base['dns_status'] ?? 'pending') === 'pending') {
$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']);
}
}
$base['dns_message'] = $meta['pending_message'];
$suggested[] = $base;
}
@@ -381,6 +381,7 @@ class Domains extends Component
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
$this->persistAllDomainDnsStatuses();
return;
@@ -412,6 +413,24 @@ class Domains extends Component
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
$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
@@ -525,40 +544,40 @@ class Domains extends Component
*/
protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array
{
$pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp);
$pendingMessage = 'Not configured yet.';
$redirect = $this->normalizeRedirect($redirectOverride);
return match ($redirect) {
'www' => $suggestedIsWww
? [
'label' => 'Canonical www',
'label' => 'Not added · canonical www',
'role' => 'canonical',
'pending_message' => "Required as the redirect target (www). {$pointDns}",
'dns_suffix' => 'This is the canonical www host traffic should land on.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
]
: [
'label' => 'Redirect source',
'label' => 'Not added · redirect source',
'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}",
'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
'non-www' => $suggestedIsWww
? [
'label' => 'Redirect source',
'label' => 'Not added · redirect source',
'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}",
'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.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
]
: [
'label' => 'Canonical non-www',
'label' => 'Not added · canonical non-www',
'role' => 'canonical',
'pending_message' => "Required as the redirect target (non-www). {$pointDns}",
'dns_suffix' => 'This is the canonical non-www host traffic should land on.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
default => [
'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www',
'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www',
'role' => 'pair',
'pending_message' => "Also add this host so both www and non-www work. {$pointDns}",
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
};
@@ -663,6 +682,43 @@ class Domains extends Component
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).
*
@@ -710,10 +766,13 @@ class Domains extends Component
$this->pendingRedirectServiceApplicationId = $app->id;
// 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;
}
$this->pendingAction = null;
$this->pendingRedirectServiceApplicationId = null;
return true;
}
@@ -766,6 +825,11 @@ class Domains extends Component
}
$newUrls = $this->splitDomains($normalized);
$pairedUrls = collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all();
$current = collect($this->splitDomains($app->fqdn));
foreach ($newUrls as $url) {
if ($current->contains($url)) {
@@ -785,10 +849,10 @@ class Domains extends Component
}
}
$merged = $current->merge($newUrls)->unique()->values();
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add';
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $merged)) {
return;
}
@@ -802,7 +866,7 @@ class Domains extends Component
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains();
$this->checkUrlsDns($newUrls, (int) $app->id);
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -874,6 +938,7 @@ class Domains extends Component
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = $dnsFailure;
$this->showEditDomainModal = true;
return;
}
@@ -882,7 +947,7 @@ class Domains extends Component
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainListForApp($app, $updated, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $updated)) {
return;
}
@@ -917,7 +982,7 @@ class Domains extends Component
$this->forceSaveDomains = true;
$this->forceRemovePort = true;
if (! $this->saveDomainListForApp($app, $updated, checkDns: false, checkConflicts: false)) {
if (! $this->saveDomainListForApp($app, $updated, checkConflicts: false)) {
return;
}
@@ -970,7 +1035,6 @@ class Domains extends Component
$this->forceAddSuggestedIndex = $index;
$this->editingIndex = $index;
$this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'DNS validation failed.', $dnsFailure);
return;
}
@@ -980,7 +1044,7 @@ class Domains extends Component
$this->pendingAction = 'suggested';
$this->editingIndex = $index;
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $merged)) {
return;
}
@@ -1035,7 +1099,6 @@ class Domains extends Component
protected function saveDomainListForApp(
ServiceApplication $app,
Collection $domains,
bool $checkDns = true,
bool $checkConflicts = true,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
@@ -1078,25 +1141,6 @@ class Domains extends Component
}
}
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 ?? '');
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+111 -18
View File
@@ -37,6 +37,14 @@ class Storage extends Component
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()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -57,12 +65,18 @@ class Storage extends Component
}
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->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()
@@ -73,37 +87,110 @@ class Storage extends Component
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) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
}
});
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()
{
return $this->fileStorage->where('is_directory', false);
return collect($this->fileStorage)->where('is_directory', false);
}
public function getDirectoriesProperty()
{
return $this->fileStorage->where('is_directory', true);
return collect($this->fileStorage)->where('is_directory', true);
}
public function getVolumeCountProperty()
{
return $this->resource->persistentStorages()->count();
return $this->cachedVolumeCount;
}
public function getFileCountProperty()
{
return $this->files->count();
return $this->cachedFileCount;
}
public function getDirectoryCountProperty()
{
return $this->directories->count();
return $this->cachedDirectoryCount;
}
public function submitPersistentVolume()
@@ -130,12 +217,12 @@ class Storage extends Component
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
$this->clearForm();
$this->activeTab = 'volumes';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -165,11 +252,13 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -198,11 +287,13 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -235,11 +326,13 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'directories';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -43,11 +43,6 @@ class ConfigurationChecker extends Component
return view('livewire.project.shared.configuration-checker');
}
public function refreshConfigurationChanges(): void
{
$this->configurationChanged();
}
/**
* Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser.
@@ -80,18 +75,42 @@ class ConfigurationChecker extends Component
}
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();
if ($this->resource instanceof Application) {
$diff = $this->resource->pendingDeploymentConfigurationDiff();
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$this->isConfigurationChanged = $diff->isChanged();
$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;
return;
@@ -32,6 +32,14 @@ class All extends Component
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 $perPage = 10;
@@ -79,7 +87,8 @@ class All extends Component
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [Application::class];
$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;
}
// Intentionally skip loading env vars / developer-view bulk text here.
@@ -357,6 +366,78 @@ class All extends Component
$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
{
$this->page = max(1, min($page, $this->environmentVariableLastPage));
@@ -388,35 +469,35 @@ class All extends Component
$segments = [];
if ($includeProduction) {
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
if ($this->showsHardcodedEnvironmentVariables()) {
if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => false,
'count' => $this->hardcodedEnvironmentVariables->count(),
];
}
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
}
if ($includePreview) {
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
if ($this->showsHardcodedEnvironmentVariables()) {
if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => true,
'count' => $this->hardcodedEnvironmentVariablesPreview->count(),
];
}
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
}
return $segments;
@@ -429,6 +510,12 @@ class All extends Component
->where('resourceable_id', $this->resource->id)
->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");
if ($this->searchTerm() !== '') {
@@ -436,7 +523,26 @@ class All extends Component
$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');
} else {
$query->orderBy('order')->orderBy('id');
@@ -553,6 +659,12 @@ class All extends Component
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)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
@@ -600,6 +712,12 @@ class All extends Component
});
}
if ($this->serviceFilters !== []) {
$hardcodedVars = $hardcodedVars->filter(
fn ($var) => in_array($var['service_name'] ?? '', $this->serviceFilters, true)
);
}
// Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
@@ -19,6 +19,8 @@ use Livewire\Component;
class Show extends Component
{
public bool $showEnvironmentType = true;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
public $parameters;
@@ -6,6 +6,8 @@ use Livewire\Component;
class ShowHardcoded extends Component
{
public bool $showEnvironmentType = true;
public array $env;
public string $key;
+269 -7
View File
@@ -2,22 +2,284 @@
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;
class All extends Component
{
use AuthorizesRequests;
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()) {
return null;
$this->canUpdate = (bool) auth()->user()?->can('update', $this->resource);
$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();
}
public function refreshList(): void
{
$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);
}
// Use the storage with the smallest ID as the "first" one
// This ensures stability even when storages are deleted
return $this->resource->persistentStorages->sortBy('id')->first()->id;
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;
}
}
+35 -1
View File
@@ -26,6 +26,8 @@ class Show extends Component
public ?string $startedAt = null;
public bool $supportsPreviewSuffix = false;
// Explicit properties
public string $name;
@@ -39,6 +41,14 @@ class Show extends Component
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 = [
'name' => 'name',
'mountPath' => 'mount',
@@ -92,7 +102,14 @@ class Show extends Component
{
$this->syncData(false);
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
$this->refreshBackupStatus();
// 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();
}
}
#[On('refreshVolumeBackups')]
@@ -107,6 +124,8 @@ class Show extends Component
return;
}
$this->resource->loadMissing('environment.project');
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
@@ -122,6 +141,21 @@ class Show extends Component
: 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
{
$this->authorize('update', $this->resource);
+35 -4
View File
@@ -113,7 +113,11 @@ class DnsRecordHints
}
/**
* 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
*/
@@ -123,11 +127,38 @@ class DnsRecordHints
return '';
}
$lines = ["Type\tName\tValue"];
$lines = [];
$nameWidth = 0;
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.'.';
}
}
+3 -2
View File
@@ -2029,7 +2029,8 @@ function dnsGuidanceTargetAddress(?string $ipOrLabel): ?string
/**
* 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 $ipForRecordType Preferred IP for type + display (defaults to $targetLabel).
@@ -2045,7 +2046,7 @@ function dnsMismatchGuidanceMessage(?string $targetLabel, ?string $ipForRecordTy
$recordType = dnsRecordTypeForIp($address);
return "{$recordType} record → {$address}";
return "Required DNS record type {$recordType} pointing to {$address}";
}
function validateDNSEntry(string $fqdn, Server $server)
-2
View File
@@ -42,8 +42,6 @@ return [
'host' => env('PUSHER_HOST'),
'port' => env('PUSHER_PORT'),
'app_key' => env('PUSHER_APP_KEY'),
'scheme' => env('PUSHER_SCHEME', 'http'),
'force_ws' => filter_var(env('PUSHER_FORCE_WS', false), FILTER_VALIDATE_BOOLEAN),
],
'migration' => [
-1
View File
@@ -21,7 +21,6 @@ services:
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_FORCE_WS: "${PUSHER_FORCE_WS:-false}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
-1
View File
@@ -29,7 +29,6 @@ services:
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_FORCE_WS: "${PUSHER_FORCE_WS:-false}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
+269 -44
View File
@@ -75,40 +75,13 @@
@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) */
.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 */
/* Active state is a solid fill only (no accent rail / border). */
.menu-item-active .menu-item-icon,
.menu-subitem-active .menu-item-icon {
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-subitem-active,
.dark .menu-item-active,
@@ -116,6 +89,12 @@
background-image: none;
}
.menu-item-active::before,
.menu-subitem-active::before {
content: none;
display: none;
}
/* vertical connector line for a nested nav group */
.nav-children {
position: relative;
@@ -971,6 +950,40 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
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 */
@media (min-width: 1024px) {
.application-settings-section.application-settings-form {
@@ -1038,6 +1051,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
top: 6.5rem;
align-self: start;
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-y: auto;
overscroll-behavior: contain;
@@ -1111,9 +1127,11 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
.application-settings-workspace .button,
.application-settings-form .button {
height: 2rem;
min-height: 2rem;
border-radius: 8px;
padding-left: 0.75rem;
padding-right: 0.75rem;
white-space: nowrap;
}
.application-settings-workspace .form-control,
@@ -1292,6 +1310,38 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
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'] {
padding-right: 0.625rem;
}
@@ -1583,7 +1633,11 @@ input[type="search"]::-webkit-search-results-decoration {
}
.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. */
@@ -1594,7 +1648,7 @@ input[type="search"]::-webkit-search-results-decoration {
/* Env vars: collapse flag columns on tablet, card layout on phone */
@media (max-width: 1100px) {
.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;
}
@@ -1619,12 +1673,7 @@ input[type="search"]::-webkit-search-results-decoration {
@media (max-width: 900px) {
.env-table-grid {
grid-template-columns: minmax(0, 1fr) 6rem 3rem;
}
/* Also hide Comment (3) */
.env-table-grid > :nth-child(3) {
display: none;
grid-template-columns: minmax(0, 1fr) 4.8rem 6rem 3rem;
}
.env-table-grid-shared {
@@ -1670,14 +1719,14 @@ input[type="search"]::-webkit-search-results-decoration {
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(3),
.data-table-row.env-table-grid-shared > :nth-child(2) {
display: none !important;
}
/* 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(5),
.data-table-row.env-table-grid > :nth-child(6),
@@ -1695,13 +1744,10 @@ input[type="search"]::-webkit-search-results-decoration {
justify-self: end;
}
.env-type-mobile {
display: inline-flex !important;
}
}
.env-type-mobile {
display: none;
.env-managed-desktop {
display: flex;
}
@media (max-width: 640px) {
@@ -1835,6 +1881,154 @@ input[type="search"]::-webkit-search-results-decoration {
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 {
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 @@ input[type="search"]::-webkit-search-results-decoration {
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-input {
display: flex;
+11 -4
View File
@@ -126,7 +126,8 @@
}
@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.) */
@@ -144,6 +145,11 @@
@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 {
@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-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];
}
@@ -227,10 +233,11 @@
/* Indented child rows in a collapsible nav group */
@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 {
@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 {
+107
View File
@@ -12,3 +12,110 @@ document.addEventListener('livewire:navigated', () => {
// Keeping this registration independent from the current route also makes it
// available before Alpine processes terminal markup after wire:navigate.
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);
};
@@ -5,6 +5,7 @@
'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…',
'emptyText' => 'No options available.',
'live' => false,
'onChange' => null, // optional $wire method to call after a selection
'wire' => true, // false = purely client-side value (no Livewire binding)
@@ -51,7 +52,7 @@
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false">
<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">
<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"
@@ -60,6 +61,10 @@
</svg>
</button>
<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)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
+2 -2
View File
@@ -43,14 +43,14 @@
{{ $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 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"
@click.prevent.stop="open && pinned ? close() : show(true)">
@isset($icon)
{{ $icon }}
@else
<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" />
@endisset
</button>
@@ -28,8 +28,9 @@
@foreach ($items as $item)
<a @class([
'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) }}">
<x-reicon :name="$item['icon']" class="size-3.5" />
{{ $item['label'] }}
+4 -10
View File
@@ -171,15 +171,7 @@
}
}
@auth
@php
$pusherForceWs = (bool) config('constants.pusher.force_ws');
@endphp
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;
window.Echo = new EchoConstructor({
broadcaster: 'pusher',
@@ -189,11 +181,13 @@
wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}",
forceTLS: false,
encrypted: @json($pusherForceWs ? false : true),
encrypted: true,
enableStats: false,
enableLogging: true,
enabledTransports: ['ws', 'wss'],
disableStats: true,
enabledTransports: @json($pusherForceWs ? ['ws'] : ['ws', 'wss']),
// Add auto reconnection settings
enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
// Attempt to reconnect on connection lost
autoReconnect: true,
@@ -2,10 +2,11 @@
@php
$canUpdate = auth()->user()->can('update', $application);
$labelsManagedByCoolify = $application->settings->is_container_label_readonly_enabled;
// Use model UUIDs: Livewire update requests do not carry page route params.
$generalRouteParameters = [
'project_uuid' => request()->route('project_uuid'),
'environment_uuid' => request()->route('environment_uuid'),
'application_uuid' => request()->route('application_uuid'),
'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => $application->uuid,
];
@endphp
@@ -43,7 +44,7 @@
['value' => false, 'label' => 'Generated name (rolling updates)'],
['value' => true, 'label' => 'Consistent name (no rolling updates)'],
]" :disabled="! $canUpdate" />
@if ($isConsistentContainerNameEnabled === false)
@if ($isConsistentContainerNameEnabled === true)
<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>"
id="customInternalName" label="Custom container name" canGate="update"
@@ -48,7 +48,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<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">
<x-application.settings-section title="Storage backups"
@@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</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">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
@@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Configuration | Coolify
</x-slot>
<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
$applicationRouteParameters = [
@@ -238,11 +238,18 @@
@endif
</a>
@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)
<button type="button" class="menu-subitem"
: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>
</button>
@endforeach
@@ -1,7 +1,7 @@
<div>
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot>
<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
$lastPage = max(1, (int) ceil($deployments_count / $defaultTake));
@@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify
</x-slot>
<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="{
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
@@ -7,7 +7,38 @@
: 'Manage domains for this application.';
@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">
@can('update', $application)
<x-slot:actions>
@@ -19,7 +50,7 @@
@endcan
@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.
</x-callout>
@endif
@@ -84,7 +115,7 @@
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
· {{ $suggestedCount }} suggested
· {{ $suggestedCount }} not added
@endif
</p>
</div>
@@ -116,13 +147,12 @@
required />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $addDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to add it anyway?
@if (filled($addDomainDnsMessage))
<div class="pt-2">{{ $addDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
@@ -187,7 +217,7 @@
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
{{ $serviceConfigured }} domain{{ $serviceConfigured === 1 ? '' : 's' }}
@if ($serviceSuggested > 0)
· {{ $serviceSuggested }} suggested
· {{ $serviceSuggested }} not added
@endif
</p>
</div>
@@ -225,7 +255,7 @@
<div class="data-table-header domains-table-grid-compose">
<span>Domain</span>
<span>Service</span>
<span>DNS</span>
<span>DNS Check</span>
<span>Last checked</span>
<span></span>
</div>
@@ -254,7 +284,7 @@
<div class="data-table w-full">
<div class="data-table-header domains-table-grid">
<span>Domain</span>
<span>DNS</span>
<span>DNS Check</span>
<span>Last checked</span>
<span></span>
</div>
@@ -271,83 +301,94 @@
@endif
</div>
{{-- Edit domain modal --}}
@if ($showEditDomainModal)
<div x-data="{ modalOpen: @entangle('showEditDomainModal') }"
@keydown.escape.window="modalOpen = false; $wire.cancelEdit()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body">
<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"
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 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.cancelEdit()"></div>
<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"
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 flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden 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">Edit domain</h3>
<button type="button" wire:click="cancelEdit"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
@if ($editingService)
<x-forms.input label="Service" value="{{ $editingService }}" readonly />
@endif
<x-forms.input id="editingDomain" label="Domain URL"
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"
required />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $editDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" wire:click="cancelEdit">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="confirmUpdateDomainDespiteDns"
isError>
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
<div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeEditDomain() }">
<template x-teleport="body">
<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"
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 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeEditDomain()"></div>
<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"
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 flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden 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">Edit domain</h3>
<button type="button" @click="closeEditDomain()"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak 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">Service</label>
</div>
</form>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<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"
x-model="localEditingDomain" />
<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)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to save it anyway?
@if (filled($editDomainDnsMessage))
<div class="pt-2">{{ $editDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" @click="closeEditDomain()">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
</div>
</form>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</template>
</div>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
@@ -501,7 +501,7 @@
icon-name="admin">
<x-slot:contents>
<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
</button>
</x-slot:contents>
@@ -1,11 +1,21 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-[1180px] pb-4 md:pb-6 lg:pb-0">
@php
$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 = [
[
'label' => 'Settings',
'route' => 'project.application.configuration',
'active' => $routeIs('project.application.configuration'),
'active' => $isSettingsRoute,
],
[
'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,
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');
[$applicationStatusLabel, $applicationStatusType] = match (true) {
$applicationStatus->startsWith('running') => ['Running', 'success'],
@@ -276,14 +184,11 @@
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($applicationMenuItems as $menuItem)
@php
$isMobileApplicationItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a @class([
'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
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -326,15 +231,12 @@
{{-- Tabs alone may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0">
@foreach ($applicationMenuItems as $menuItem)
@php
$isApplicationMenuItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a wire:key="application-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'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
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -24,24 +24,32 @@
<div @class([
'data-table-row',
$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-wrap items-center gap-2">
<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"
title="{{ $row['url'] }}">
{{ $row['url'] }}
</a>
@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"
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'] }}">
{{ $row['url'] }}
</a>
@endif
@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
@if ($isCompose ?? false)
<span class="domains-service-mobile table-badge shrink-0">{{ $row['service'] ?? '-' }}</span>
@endif
</div>
@if ($row['dns_status'] !== 'ok' && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-neutral-500 sm:truncate dark:text-fg-dim"
@if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
title="{{ $row['dns_message'] }}">
{{ $row['dns_message'] }}
</p>
@@ -56,8 +64,13 @@
@endif
<div class="flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@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"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@endif
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@@ -84,12 +97,18 @@
Continue
</x-forms.button>
@else
<x-forms.button wire:click="addSuggestedDomain({{ $index }})" isHighlighted class="h-7! px-2! text-[12px]!">
Add
<x-forms.button wire:click="addSuggestedDomain({{ $index }})" isHighlighted class="h-7! shrink-0 px-2.5! text-[12px]!">
Add domain
</x-forms.button>
@endif
@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">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -155,8 +155,9 @@
@foreach ($databasePageItems as $menuItem)
<a @class([
'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
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -175,8 +176,9 @@
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'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
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -8,7 +8,36 @@
$singleAppId = $singleApp['id'] ?? null;
@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"
helper="Manage domains and www/non-www redirects for applications in this stack.">
@can('update', $service)
@@ -63,7 +92,7 @@
<p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
· {{ $suggestedCount }} suggested
· {{ $suggestedCount }} not added
@endif
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
@@ -97,13 +126,12 @@
required />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $addDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to add it anyway?
@if (filled($addDomainDnsMessage))
<div class="pt-2">{{ $addDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
@@ -202,90 +230,94 @@
@endif
@endif
{{-- Edit domain modal --}}
@if ($showEditDomainModal)
<div x-data="{ modalOpen: @entangle('showEditDomainModal') }"
@keydown.escape.window="modalOpen = false; $wire.cancelEdit()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body">
<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"
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 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.cancelEdit()"></div>
<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"
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 flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden 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">Edit domain</h3>
<button type="button" wire:click="cancelEdit" class="icon-button shrink-0"
aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
@php
$editingServiceLabel = collect($serviceApps)
->firstWhere('id', (int) $editingServiceApplicationId)['name']
?? data_get($domainRows, ($editingIndex ?? -1).'.service_name');
@endphp
@if (filled($editingServiceLabel))
<x-forms.input label="Service application" value="{{ $editingServiceLabel }}"
readonly
helper="Domains stay on the service they were added to. Remove and re-add to move." />
@endif
<x-forms.input id="editingDomain" label="Domain URL"
placeholder="https://app.example.com"
helper="Full URL including scheme. Optional path and container port are supported."
required />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $editDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" wire:click="cancelEdit">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="confirmUpdateDomainDespiteDns"
isError>
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
<div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeEditDomain() }">
<template x-teleport="body">
<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"
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 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeEditDomain()"></div>
<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"
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 flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden 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">Edit domain</h3>
<button type="button" @click="closeEditDomain()" class="icon-button shrink-0"
aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak 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">Service application</label>
</div>
</form>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Domains stay on the service they were added to. Remove and re-add to move.
</p>
</div>
<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"
x-model="localEditingDomain" />
@error('editingDomain')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to save it anyway?
@if (filled($editDomainDnsMessage))
<div class="pt-2">{{ $editDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" @click="closeEditDomain()">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
</div>
</form>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</template>
</div>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
@@ -31,7 +31,7 @@
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
</div>
</div>
@if ($resource instanceof \App\Models\Application)
@if ($resource instanceof \App\Models\Application && $resource->git_based())
@can('update', $resource)
<div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
@@ -47,7 +47,7 @@
<x-unsaved-bar action="submit" />
@if (!$isReadOnly)
@can('update', $resource)
<div class="flex gap-2">
<div class="flex flex-wrap items-center gap-2">
@if ($fileStorage->is_host_file)
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"
@@ -180,8 +180,9 @@
@foreach ($servicePageItems as $menuItem)
<a @class([
'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
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -204,8 +205,9 @@
<a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'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
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -47,24 +47,32 @@
<div @class([
'data-table-row',
$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-wrap items-center gap-2">
<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"
title="{{ $row['url'] }}">
{{ $row['url'] }}
</a>
@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"
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'] }}">
{{ $row['url'] }}
</a>
@endif
@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
@if ($showServiceColumn)
<span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span>
@endif
</div>
@if ($row['dns_status'] !== 'ok' && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-neutral-500 sm:truncate dark:text-fg-dim"
@if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
title="{{ $row['dns_message'] }}">
{{ $row['dns_message'] }}
</p>
@@ -79,8 +87,13 @@
@endif
<div class="flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@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"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@endif
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
@@ -109,12 +122,18 @@
@else
<x-forms.button canGate="update" :canResource="$service"
wire:click="addSuggestedDomain({{ $index }})" isHighlighted
class="h-7! px-2! text-[12px]!">
Add
class="h-7! shrink-0 px-2.5! text-[12px]!">
Add domain
</x-forms.button>
@endif
@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">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -2,10 +2,12 @@
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 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
<div class="flex flex-col gap-6" x-data="{ activeTab: '{{ $defaultTab }}' }">
<div class="flex flex-col gap-6">
@if (
$resource->getMorphClass() == 'App\Models\Application' ||
$resource->getMorphClass() == 'App\Models\StandalonePostgresql' ||
@@ -16,8 +18,10 @@
$resource->getMorphClass() == 'App\Models\StandaloneClickhouse' ||
$resource->getMorphClass() == 'App\Models\StandaloneMongodb' ||
$resource->getMorphClass() == 'App\Models\StandaloneMysql')
<x-application.settings-section id="storage-mounts-section" title="Persistent storage"
helper="Preview deployment volumes can use a -pr-#PRNumber suffix so each pull request receives isolated storage.">
<x-application.settings-section id="storage-mounts-section" title="Persistent storage" :flush="true"
: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>
@if ($resource?->build_pack !== 'dockercompose')
@can('update', $resource)
@@ -353,28 +357,19 @@
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div
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'"
: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'"
<button type="button" wire:click="setActiveTab('volumes')"
@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 }})
</button>
<button type="button" @click="activeTab = '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'"
<button type="button" wire:click="setActiveTab('files')"
@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 }})
</button>
<button type="button" @click="activeTab = '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'"
<button type="button" wire:click="setActiveTab('directories')"
@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 }})
</button>
</div>
@@ -382,140 +377,109 @@
</x-slot:actions>
@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."
icon-name="storages" />
@else
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-6">
@if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" />
@else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No volumes configured.
</div>
@endif
</div>
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-6">
@elseif ($activeTab === 'volumes')
@if ($hasVolumes)
<livewire:project.shared.storages.all wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
:resource="$resource" />
@else
<x-empty size="sm" title="No volumes configured"
description="Switch tabs or add a volume mount." icon-name="storages" />
@endif
@elseif ($activeTab === 'files')
<div class="flex flex-col gap-4 p-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No file mounts configured.
</div>
<x-empty size="sm" title="No file mounts configured"
description="Switch tabs or add a file mount." icon-name="file" />
@endif
</div>
{{-- Directories Tab --}}
<div x-show="activeTab === 'directories'" class="flex flex-col gap-6">
@else
<div class="flex flex-col gap-4 p-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No directory mounts configured.
</div>
<x-empty size="sm" title="No directory mounts configured"
description="Switch tabs or add a directory mount." icon-name="folder" />
@endif
</div>
@endif
</x-application.settings-section>
@else
<div class="flex flex-col gap-4 py-2">
<div>
<div class="flex items-center gap-2">
<h2>{{ Str::headline($resource->name) }}</h2>
</div>
</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)
<div x-data="{
activeTab: '{{ $defaultTab }}'
}">
{{-- Tabs Navigation --}}
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
<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">
{{-- Service stack resources: one settings card + table per service --}}
<x-application.settings-section :id="'storage-service-'.$resource->id"
:title="Str::headline($resource->name)" :flush="true"
helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard.">
<x-slot:actions>
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div
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')"
@disabled(!$hasVolumes)
@class([$tabButtonBase, $activeTab === 'volumes' ? $tabButtonActive : $tabButtonInactive])>
Volumes ({{ $this->volumeCount }})
</button>
<button @click="activeTab = 'files'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@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">
<button type="button" wire:click="setActiveTab('files')"
@disabled(!$hasFiles)
@class([$tabButtonBase, $activeTab === 'files' ? $tabButtonActive : $tabButtonInactive])>
Files ({{ $this->fileCount }})
</button>
<button @click="activeTab = 'directories'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@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">
<button type="button" wire:click="setActiveTab('directories')"
@disabled(!$hasDirectories)
@class([$tabButtonBase, $activeTab === 'directories' ? $tabButtonActive : $tabButtonInactive])>
Directories ({{ $this->directoryCount }})
</button>
</div>
@endif
</x-slot:actions>
{{-- 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
</div>
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No file mounts configured.
</div>
@endif
</div>
{{-- Directories Tab --}}
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No directory mounts configured.
</div>
@endif
</div>
</div>
@if (!$hasVolumes && !$hasFiles && !$hasDirectories)
<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)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<x-empty size="sm" title="No file mounts configured"
description="This service has no file mounts." icon-name="file" />
@endif
</div>
@else
<div class="flex flex-col gap-4 p-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<x-empty size="sm" title="No directory mounts configured"
description="This service has no directory mounts." icon-name="folder" />
@endif
</div>
@endif
</div>
</x-application.settings-section>
@endif
</div>
@@ -20,7 +20,7 @@
</button>
@endif
<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" />
Manual records
</button>
@@ -97,143 +97,162 @@
</div>
@endif
@if ($showDnsRecordsModal)
@php
$dnsHints = $this->dnsRecordHints();
$dnsCopyText = $this->dnsRecordsCopyText();
@endphp
<div x-data="{ modalOpen: @entangle('showDnsRecordsModal') }"
@keydown.escape.window="modalOpen = false; $wire.closeDnsRecordsModal()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body">
<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"
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 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.closeDnsRecordsModal()"></div>
<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"
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 flex w-full max-w-2xl flex-col overflow-hidden"
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">DNS entries</h3>
<button type="button" wire:click="closeDnsRecordsModal"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body flex flex-col gap-4">
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Hosts that still need DNS at your provider (working domains are omitted). Create matching
Type / Name / Value records so traffic reaches this server.
</p>
{{-- Always mounted so open/close is Alpine-only (no Livewire round-trip). --}}
@php
$dnsHints = $this->dnsRecordHints();
$dnsCopyText = $this->dnsRecordsCopyText();
@endphp
<div
x-data="{
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">
<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"
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 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeDnsRecords()"></div>
<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"
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 flex w-full max-w-2xl flex-col overflow-hidden"
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">DNS entries</h3>
<button type="button" @click="closeDnsRecords()"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body flex flex-col gap-4">
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Hosts that still need DNS at your provider (working domains are omitted). Create matching
Type / Name / Value records so traffic reaches this server.
</p>
@if (blank($serverIp) && count($dnsHints) === 0)
<x-callout type="warning" title="No server IP">
Could not determine a public IP for this destination. Set the server IP (or instance public IPv4 for localhost) first.
</x-callout>
@elseif (count($dnsHints) === 0)
<x-callout type="info" title="Nothing to configure">
No pending DNS entries. All listed domains already resolve correctly, or no domains are configured yet.
Use Recheck after changing DNS.
</x-callout>
@else
<div class="overflow-x-auto rounded-md border border-neutral-200 dark:border-coolgray-300">
<table class="w-full min-w-[32rem] text-left text-sm">
<thead
class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:bg-coolgray-100 dark:text-fg-dim">
<tr>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Value</th>
@if (blank($serverIp) && count($dnsHints) === 0)
<x-callout type="warning" title="No server IP">
Could not determine a public IP for this destination. Set the server IP (or instance public IPv4 for localhost) first.
</x-callout>
@elseif (count($dnsHints) === 0)
<x-callout type="info" title="Nothing to configure">
No pending DNS entries. All listed domains already resolve correctly, or no domains are configured yet.
Use Recheck after changing DNS.
</x-callout>
@else
<div class="overflow-x-auto rounded-md border border-neutral-200 dark:border-coolgray-300">
<table class="w-full min-w-[32rem] text-left text-sm">
<thead
class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:bg-coolgray-100 dark:text-fg-dim">
<tr>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300">
@foreach ($dnsHints as $record)
<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">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['name'],
'label' => 'Copy name',
'break' => true,
])
</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['value'],
'label' => 'Copy value',
'break' => true,
])
</td>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300">
@foreach ($dnsHints as $record)
<tr class="font-mono text-[13px] text-black dark:text-fg">
<td class="px-3 py-2.5">{{ $record['type'] }}</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['name'],
'label' => 'Copy name',
'break' => true,
])
</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['value'],
'label' => 'Copy value',
'break' => true,
])
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if (filled($dnsCopyText))
<div class="flex flex-wrap items-center justify-between gap-2"
x-data="{
copied: false,
async copyAll(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
this.copied = true;
setTimeout(() => this.copied = false, 1000);
} catch (e) {
console.error('Copy failed', e);
}
}
}">
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
{{ count($dnsHints) }}
{{ count($dnsHints) === 1 ? 'entry' : 'entries' }}
· Type / Name / Value
</p>
<button type="button" class="button shrink-0"
@click.prevent="copyAll(@js($dnsCopyText))">
<span x-text="copied ? 'Copied' : 'Copy all'"></span>
</button>
</div>
@endif
@endif
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<x-forms.button type="button" wire:click="recheckDnsRecordsInModal"
wire:target="recheckDnsRecordsInModal,checkAllDns,checkDomainDns"
title="Recheck DNS">
<x-reicon name="refresh" class="size-3.5" />
Recheck
</x-forms.button>
<x-forms.button type="button" wire:click="closeDnsRecordsModal" isHighlighted>
Done
</x-forms.button>
@endforeach
</tbody>
</table>
</div>
@if (filled($dnsCopyText))
<div class="flex flex-wrap items-center justify-between gap-2"
x-data="{
copied: false,
async copyAll(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
this.copied = true;
setTimeout(() => this.copied = false, 1000);
} catch (e) {
console.error('Copy failed', e);
}
}
}">
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
{{ count($dnsHints) }}
{{ count($dnsHints) === 1 ? 'entry' : 'entries' }}
· BIND zone format
</p>
<button type="button" class="button shrink-0"
title="Copy as BIND-compatible zone file"
@click.prevent="copyAll(@js($dnsCopyText))">
<span x-text="copied ? 'Copied' : 'Copy all'"></span>
</button>
</div>
@endif
@endif
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<x-forms.button type="button" @click="recheckDns()"
wire:target="recheckDnsRecordsInModal,checkAllDns,checkDomainDns"
title="Recheck DNS">
<x-reicon name="refresh" class="size-3.5" />
Recheck
</x-forms.button>
<x-forms.button type="button" @click="closeDnsRecords()" isHighlighted>
Done
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</template>
</div>
@@ -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">
<x-application.settings-section id="environment-variables-section" title="Environment variables"
helper="Environment variables (secrets) for this resource.">
@@ -77,40 +91,114 @@
</div>
</div>
<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">
<button type="button" class="button" @click="open = !open" @click.outside="open = false"
aria-haspopup="listbox" :aria-expanded="open" @disabled(! $readyToLoad)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8"
stroke="currentColor" class="size-3.5">
<path stroke-linecap="round" stroke-linejoin="round"
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>
Filter
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-48!" x-show="open" x-cloak
role="listbox">
@foreach ([
'all' => 'All environments',
'production' => 'Production',
'preview' => 'Preview',
] as $filterValue => $filterLabel)
<button type="button" class="listbox-option" role="option"
aria-selected="{{ $environmentFilter === $filterValue ? 'true' : 'false' }}"
wire:click="setEnvironmentFilter('{{ $filterValue }}')" @click="open = false">
<span class="truncate">{{ $filterLabel }}</span>
@if ($environmentFilter === $filterValue)
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2.5" stroke="currentColor" class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round"
d="m4.5 12.75 6 6 9-13.5" />
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false"
@if ($activeFilterCount > 0) title="{{ $activeFilterText }}" @endif
@class([
'button max-w-80 min-w-0',
'bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 dark:bg-warning/15! dark:text-warning! dark:ring-warning/25' => $activeFilterCount > 0,
])>
<x-reicon name="filter" class="size-3.5 shrink-0" />
<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>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open" x-cloak>
<div class="max-h-80 overflow-y-auto p-1">
@foreach ([
'managed' => 'Managed',
'user' => 'User-defined',
'buildtime' => 'Buildtime',
'runtime' => 'Runtime',
'multiline' => 'Multiline',
'literal' => 'Literal',
] as $value => $label)
<button type="button" class="listbox-option" wire:click="toggleVariableFilter('{{ $value }}')">
<span>{{ $label }}</span>
@php
$selected = in_array($value, $variableFilters, 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' => $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>
@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>
@endif
</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>
@endforeach
</div>
</div>
@can('manageEnvironment', $resource)
{{-- Do not disable Add based on readyToLoad: modal-input uses wire:ignore, so a
disabled attribute painted on first load would never re-enable. --}}
@@ -145,34 +233,45 @@
$lastVisibleRow = min($currentPage * $perPage, $totalRows);
@endphp
<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)
<x-empty size="sm" title="No environment variables found"
description="No variables match your search." />
@elseif ($totalRows > 0)
<div class="data-table w-full transition-opacity"
wire:loading.class="opacity-50 pointer-events-none"
wire:target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
<div class="data-table-header env-table-grid">
<div class="data-table w-full">
<div class="relative">
<div class="transition-all"
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>Type</span>
<span>Comment</span>
<span class="text-center">Managed</span>
@if ($showEnvironmentType)
<span>Type</span>
@endif
<span class="text-center">Literal</span>
<span class="text-center">Multiline</span>
<span class="text-center">Buildtime</span>
<span class="text-center">Runtime</span>
<span></span>
</div>
@foreach ($this->environmentVariablePageRows as $row)
@foreach ($this->environmentVariablePageRows as $row)
@if ($row['kind'] === 'managed')
<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
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']"
:isPreview="$row['scope'] === 'preview'" />
:isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" />
@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"
:current-page="$currentPage" :last-page="$lastPage"
wire-target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
@@ -182,9 +281,17 @@
last-action="setEnvironmentVariablePage({{ $lastPage }})" />
</div>
@else
<x-empty size="sm" title="No environment variables"
description="Add your first variable with the + Add button above."
icon-name="variables" />
<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"
description="Add your first variable with the + Add button above."
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
</div>
@endif
@@ -1,24 +1,47 @@
<div class="env-table-item"
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">
<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)
<span class="table-badge shrink-0">{{ $serviceName }}</span>
@endif
<span class="env-type-mobile table-badge shrink-0">{{ $isPreview ? 'Preview' : 'Production' }}</span>
</div>
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $isPreview ? 'Preview' : 'Production' }}
<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>
@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>
</x-modal-input>
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $comment ?: ($value !== null && $value !== '' ? '-' : 'Inherited from host') }}
</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>
@@ -12,7 +12,7 @@
@if ($isSharedVariable) :style="`order: ${sharedSort === 'alphabetical' ? {{ $tableAlphabeticalOrder }} : {{ $tableCreationOrder }}}`" @endif
x-show="(typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $rowScope }}')
&& (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">
@if ($isLocked)
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
@@ -26,21 +26,34 @@
@endif
<span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg"
title="{{ $env->key }}">{{ $env->key }}</span>
@if (! $isSharedVariable && filled($comment))
<x-helper :helper="e($comment)" />
@endif
@if ($is_really_required)
<span class="table-badge table-badge-danger shrink-0">Required</span>
@endif
</div>
@if (! $isSharedVariable)
@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
<span class="env-type-mobile table-badge shrink-0">{{ $rowScopeLabel }}</span>
</div>
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $rowScopeLabel }}
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@if ($comment) title="{{ $comment }}" @endif>
{{ $comment ?: '-' }}
</div>
@endif
@if ($showEnvironmentType)
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ $rowScopeLabel }}</div>
@endif
@if ($isSharedVariable)
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@if ($comment) title="{{ $comment }}" @endif>
{{ $comment ?: '-' }}
</div>
@endif
@if ($isSharedVariable)
@if ($is_multiline)
<span class="data-table-cell-check">
@@ -5,7 +5,7 @@
@if ($type === 'application')
<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')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.database.heading :database="$resource" />
@@ -6,7 +6,7 @@
<livewire:project.shared.configuration-checker :resource="$resource" />
@if ($type === 'application')
<livewire:project.application.heading :application="$resource" />
<livewire:project.application.heading :application="$resource" wire:key="application-heading-logs" />
@elseif ($type === 'database')
<livewire:project.database.heading :database="$resource" />
@elseif ($type === 'service')
@@ -3,6 +3,13 @@
<x-application.settings-section id="cpu-limits-section" title="CPU"
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">
<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."
@@ -14,12 +21,6 @@
helper="Relative CPU scheduling weight. Docker uses 1024 by default."
label="CPU weight" id="limitsCpuShares" />
</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 id="memory-limits-section" title="Memory"
@@ -110,7 +110,8 @@
<x-forms.listbox id="clone-resource-destination" label="Network destination" :wire="false"
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 x-show="selectedCloneDestination" x-cloak
@@ -1,16 +1,223 @@
<div class="flex flex-col gap-6">
@if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose')
<div class="w-full rounded-lg bg-warning/10 p-2 text-sm text-warning">
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.
@php
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$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>
@endif
@if ($resource->persistentStorages->isNotEmpty())
<div class="data-table w-full">
<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
<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>
<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
@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
@foreach ($resource->persistentStorages as $storage)
@if ($resource->type() === 'service')
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' />
@else
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" />
@endif
@endforeach
</div>
@@ -1,182 +1,156 @@
<div>
<form wire:submit='submit' class="flex flex-col gap-4">
@if ($isReadOnly)
@if (!$storage->isServiceResource() && !$storage->isDockerComposeResource())
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@endif
@if ($isFirst)
<div class="grid w-full gap-4 md:grid-cols-3">
@if (
$storage->resource_type === 'App\Models\ServiceApplication' ||
$storage->resource_type === 'App\Models\ServiceDatabase')
<x-forms.input id="name" label="Volume Name" required readonly
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing.">
<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>
@else
<x-forms.input id="name" label="Volume Name" required readonly
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing.">
<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>
@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 />
@php
$showActionsColumn = $resource instanceof \App\Models\Application;
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$showActionsColumn => 'volumes-table-grid',
default => 'volumes-table-grid-readonly',
};
$canUpdate = auth()->user()?->can('update', $resource) ?? false;
$inputsReadonly = $isReadOnly || ! $canUpdate;
$displayHostPath = filled($hostPath) ? $hostPath : '—';
@endphp
@if ($inputsReadonly)
{{-- Read-only: plain data-table row (service / compose / no permission) --}}
<div class="env-table-item" wire:key="storage-row-{{ $storage->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="{{ $name }}">{{ $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
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
Backup
</span>
@endif
@endif
</div>
@else
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required readonly>
<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" readonly />
<x-forms.input id="mountPath" required readonly />
</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="{{ $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="{{ $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>
@endif
@if (!$isService)
@can('update', $resource)
<div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
helper="Choose whether preview deployments receive an isolated -pr-N volume suffix."
onChange="instantSave" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endcan
@if ($showActionsColumn)
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@if ($canUpdate)
@if ($showBackupModal)
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
wireOpen="showBackupModal">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@else
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif
@else
<span class="text-neutral-400 dark:text-fg-faint"></span>
@endif
</div>
@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"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@endcan
</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
<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="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="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' => false, 'label' => 'Share volume'],
]" />
</div>
@endif
@else
@can('update', $resource)
@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
</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>
@else
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" 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
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" />
<x-forms.input id="mountPath" required />
</div>
@endif
@if (!$isService)
<div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
helper="Choose whether preview deployments receive an isolated -pr-N volume suffix."
onChange="instantSave" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endif
<div class="flex gap-2">
<x-forms.button type="submit">
Update
</x-forms.button>
@if ($resource instanceof \App\Models\Application)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<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)
@if ($showBackupModal)
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
wireOpen="showBackupModal">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@else
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :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="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
</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>
@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
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :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="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
</div>
</div>
</form>
</div>
@endif
@@ -288,8 +288,9 @@
@foreach ($serverMenuItems as $menuItem)
<a @class([
'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
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
@@ -308,8 +309,9 @@
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'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
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
+2 -10
View File
@@ -12,18 +12,10 @@
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
@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/pusher.js') }}"></script>
<script>
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;
window.Echo = new EchoConstructor({
broadcaster: 'pusher',
@@ -33,10 +25,10 @@
wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}",
forceTLS: false,
encrypted: @json($pusherForceWs ? false : true),
encrypted: true,
enableStats: false,
enableLogging: @json(app()->environment('local')),
enabledTransports: @json($pusherForceWs ? ['ws'] : ['ws', 'wss']),
enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
});
</script>
+145 -18
View File
@@ -189,7 +189,8 @@ it('adds a domain to the application', function () {
$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 () {
@@ -218,7 +219,8 @@ it('blocks adding a domain with bad dns until the user continues', function () {
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
->call('addDomain')
->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();
expect($this->application->fqdn)->toBeNull();
@@ -229,7 +231,10 @@ it('blocks adding a domain with bad dns until the user continues', function () {
->assertDispatched('close-modal');
$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 () {
@@ -281,7 +286,8 @@ it('blocks editing a domain with bad dns until the user continues', function ()
->call('updateDomain')
->assertSet('editDomainDnsFailed', 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();
expect($this->application->fqdn)->toBe('https://old.example.com');
@@ -367,6 +373,101 @@ it('auto-adds missing www counterpart as a normal domain when setting www redire
->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 () {
$this->application->update([
'fqdn' => 'https://www.example.com',
@@ -479,7 +580,25 @@ it('loads persisted dns status on page load', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.dns_status', 'failed')
->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 () {
@@ -550,7 +669,7 @@ it('resolves hostname server addresses to a real ip for dns messages', function
$message = $component->get('domainRows.0.dns_message');
$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') {
expect($message)->toBe("{$recordType} record → {$resolvedIp}")
->and($message)->not->toContain('CNAME');
@@ -574,7 +693,7 @@ it('uses short aaaa guidance when the server ip is ipv6', function () {
->call('checkDomainDns', 0);
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 () {
@@ -596,7 +715,7 @@ it('uses short a-record guidance for compose applications', function () {
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->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');
});
@@ -608,7 +727,10 @@ it('normalizes domains before saving', function () {
$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 () {
@@ -622,23 +744,28 @@ it('shows the missing www counterpart as a suggested domain row', function () {
->assertSet('domainRows.0.is_suggested', false)
->assertSet('domainRows.1.url', 'https://www.example.com')
->assertSet('domainRows.1.is_suggested', true)
->assertSee('Suggested www')
->assertSee('Add')
->assertSet('domainRows.1.suggestion_label', null)
->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');
});
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([
'fqdn' => 'https://example.com',
'redirect' => 'both',
]);
$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')
->set('redirect', 'www')
// 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');
expect($this->application->fresh()->redirect)->toBe('both');
@@ -1138,20 +1265,20 @@ it('auto-adds missing www pair for a single compose service redirect', function
->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([
'build_pack' => 'dockercompose',
'fqdn' => null,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
'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()])
->set('isCompose', true)
->set('composeServices', ['web'])
->set('serviceRedirects.web', 'www');
->set('serviceRedirects.web', 'both');
$component->instance()->domainRows = (function () use ($component) {
$method = new ReflectionMethod($component->instance(), 'buildDomainRows');
@@ -1162,6 +1289,6 @@ it('uses compose service redirect for suggested domain messaging', function () {
$suggested = collect($component->get('domainRows'))->firstWhere('is_suggested', true);
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');
});
@@ -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');
});
+7 -2
View File
@@ -35,11 +35,16 @@ it('builds entries for every hostname without duplicates', function () {
->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([
['type' => 'A', 'name' => 'app.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"
);
});
@@ -31,6 +31,17 @@ beforeEach(function () {
$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 () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -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'/");
});
+18 -3
View File
@@ -87,7 +87,7 @@ test('livewire file storage rejects parent segments and does not create a local
test('file mount modal shows the calculated host file path above the destination input', function () {
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($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
@@ -136,7 +136,10 @@ test('livewire volume storage refreshes the storage list and configuration warni
->call('submitPersistentVolume')
->assertDispatched('success')
->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 () {
@@ -159,10 +162,22 @@ test('volume storage list shows volumes added after it was mounted', function ()
$storageList
->assertDontSee($secondVolume->name)
->dispatch('refreshStorages')
->call('refreshList')
->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 () {
$file = LocalFileVolume::create([
'fs_path' => '/etc/nginx/nginx.conf',
+50
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');
});
+1
View File
@@ -12,6 +12,7 @@ test('helper trigger is a button that stops label activation', function () {
->toContain('aria-label="More information"')
->toContain('info-helper-popup')
->toContain('name="info-circle"')
->toContain('class="size-3.5 text-neutral-400')
->not->toContain('<div x-ref="trigger" class="info-helper"');
});
@@ -44,6 +44,28 @@ test('searchable listbox component uses shared trigger label truncation', functi
->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 () {
$html = Blade::render(<<<'BLADE'
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
@@ -65,10 +65,13 @@ it('renders the changed configuration labels', function () {
$application->update(['build_command' => 'pnpm build']);
// Banner summary is always available; full change rows load on demand.
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->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 () {
@@ -85,6 +88,7 @@ it('refreshes configuration changes when the event is received', function () {
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->call('refreshConfigurationChanges')
->assertSee('Build command');
});
@@ -108,8 +112,9 @@ it('shows an unapplied configuration warning after a directory mount is added',
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->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 () {
@@ -119,6 +124,7 @@ it('refreshes stale modal configuration diff before opening changes', function (
$application->update(['build_command' => 'pnpm build']);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges')
->assertSee('Build command')
->assertDontSee('Start command');
@@ -134,7 +140,29 @@ it('refreshes stale modal configuration diff before opening changes', function (
->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);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
@@ -149,15 +177,26 @@ it('does not render environment variable secret values', function () {
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('API_TOKEN')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('old-secret')
->assertDontSee('new-secret');
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->call('refreshConfigurationChanges');
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|| 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);
markConfigurationCheckerApplicationDeployed($application);
@@ -171,12 +210,18 @@ it('renders added environment variables as set without exposing secret values',
'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('From')
->assertSee('-')
->assertSee('To')
->assertSee('••••••••')
->assertDontSee('Hidden')
->assertDontSee('new-secret');
->assertSee('Current')
->assertSee('New');
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|| 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');
});
@@ -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');
});
@@ -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');
});
+30
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');
});
@@ -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);
});
@@ -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');
});
@@ -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');
});
@@ -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);
});
@@ -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');
});
+47 -5
View File
@@ -177,6 +177,21 @@ it('auto-adds missing non-www pair for a service application redirect', function
->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 () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
@@ -186,7 +201,8 @@ it('adds a domain to a selected service application', function () {
->assertDispatched('success');
$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 () {
@@ -282,8 +298,10 @@ it('does not restore stale dns status when a removed service domain is re-added'
$this->apiApp->refresh();
expect($this->apiApp->fqdn)->toBe('https://api.example.com')
->and($this->apiApp->domain_dns_statuses)->toBeNull();
expect(explode(',', (string) $this->apiApp->fqdn))
->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 () {
@@ -317,7 +335,8 @@ YAML,
->assertSet('pendingAction', null)
->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 () {
@@ -333,7 +352,30 @@ it('loads persisted dns status for service applications', function () {
]);
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 () {
@@ -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');
});
+14 -8
View File
@@ -366,14 +366,16 @@ it('shows the configure backup modal trigger inside the volume card instead of i
'resource' => $application,
])
->set('isReadOnly', true)
->assertSee('Configure Backup')
->assertSee('Backup')
->assertDontSee('Backups made while the application is writing');
$html = $component->html();
expect(strpos($html, 'Configure Backup'))
->toBeGreaterThan(strpos($html, '<form'))
->toBeLessThan(strpos($html, '</form>'));
// Read-only volume rows are table cells (no form); backup action still renders in the row.
expect($html)
->toContain('Configure Volume Backup')
->toContain('data-table-row')
->toContain('Backup');
});
it('only shows the backup enabled badge for an enabled volume backup', function () {
@@ -391,7 +393,7 @@ it('only shows the backup enabled badge for an enabled volume backup', function
$component = Livewire::test(Show::class, [
'storage' => $volume,
'resource' => $application,
])->assertDontSee('Backup enabled');
])->assertDontSee('table-badge-success', false);
$backup->update(['enabled' => true]);
@@ -404,14 +406,17 @@ it('only shows the backup enabled badge for an enabled volume backup', function
$component
->dispatch('refreshVolumeBackups')
->assertSeeInOrder(['Volume Name', 'Backup enabled'])
->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false);
Livewire::test(Show::class, [
'storage' => $volume,
'resource' => $application,
'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 () {
@@ -441,7 +446,8 @@ it('links the backup enabled badge to a filtered backup list when the applicatio
'storage' => $volume,
'resource' => $application,
])
->assertSee('Backup enabled')
->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false);
});
+3 -3
View File
@@ -20,7 +20,7 @@ it('defaults to A when the address is missing or not an ip', 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');
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('continue');
});
@@ -28,14 +28,14 @@ it('builds a short A-record guidance message for ipv4 targets', function () {
it('builds a short AAAA-record guidance message for ipv6 targets', function () {
$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('continue');
});
it('prefers the bare ip over a hostname label', function () {
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 () {