mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 18:24:13 +00:00
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.'.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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'] }}
|
||||
|
||||
@@ -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 A–Z', 'name_desc' => 'Name Z–A'] 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
|
||||
|
||||
+36
-13
@@ -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'] }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'/");
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user