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

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

No files matched your search

+97 -53
View File
@@ -158,6 +158,10 @@ class Domains extends Component
$this->newServiceApplicationId = $this->serviceApps[0]['id'];
}
// Do not auto-promote www/non-www pairs here: load/refresh also runs after
// removeDomain, and re-adding counterparts would undo intentional deletes.
// Pairs are still ensured on setServiceRedirect, addDomain, etc.
$this->domainRows = $this->buildDomainRows();
}
@@ -197,7 +201,10 @@ class Domains extends Component
}
}
return $rows;
return collect($rows)
->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1)
->values()
->all();
}
/**
@@ -282,17 +289,10 @@ class Domains extends Component
$base['is_suggested'] = true;
$base['suggested_for'] = $url;
$base['suggestion_label'] = $meta['label'];
$base['suggestion_label'] = null;
$base['suggestion_role'] = $meta['role'];
$base['needs_force_add'] = false;
if (($base['dns_status'] ?? 'pending') === 'pending') {
$base['dns_message'] = $meta['pending_message'];
} elseif (in_array($base['dns_status'], ['ok', 'failed', 'skipped'], true)) {
if ($meta['role'] !== 'pair' && ! str_contains((string) $base['dns_message'], 'redirect')) {
$base['dns_message'] = trim((string) $base['dns_message'].' '.$meta['dns_suffix']);
}
}
$base['dns_message'] = $meta['pending_message'];
$suggested[] = $base;
}
@@ -381,6 +381,7 @@ class Domains extends Component
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
$this->persistAllDomainDnsStatuses();
return;
@@ -412,6 +413,24 @@ class Domains extends Component
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
}
/**
* Keep suggested-row copy short after a DNS check (no role badge).
*/
protected function decorateSuggestedDomainAfterDnsCheck(int $index): void
{
if (! ($this->domainRows[$index]['is_suggested'] ?? false)) {
return;
}
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$appId = (int) ($this->domainRows[$index]['service_application_id'] ?? 0);
$meta = $this->suggestedDomainMeta($isWww, $this->serviceRedirectFor($appId > 0 ? $appId : null));
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
}
protected function persistAllDomainDnsStatuses(): void
@@ -525,40 +544,40 @@ class Domains extends Component
*/
protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array
{
$pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp);
$pendingMessage = 'Not configured yet.';
$redirect = $this->normalizeRedirect($redirectOverride);
return match ($redirect) {
'www' => $suggestedIsWww
? [
'label' => 'Canonical www',
'label' => 'Not added · canonical www',
'role' => 'canonical',
'pending_message' => "Required as the redirect target (www). {$pointDns}",
'dns_suffix' => 'This is the canonical www host traffic should land on.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
]
: [
'label' => 'Redirect source',
'label' => 'Not added · redirect source',
'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}",
'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
'non-www' => $suggestedIsWww
? [
'label' => 'Redirect source',
'label' => 'Not added · redirect source',
'role' => 'redirect_source',
'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}",
'dns_suffix' => 'Used only so Coolify can redirect this host to non-www. Still needs DNS to the server, not a provider URL-redirect record.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
]
: [
'label' => 'Canonical non-www',
'label' => 'Not added · canonical non-www',
'role' => 'canonical',
'pending_message' => "Required as the redirect target (non-www). {$pointDns}",
'dns_suffix' => 'This is the canonical non-www host traffic should land on.',
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
default => [
'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www',
'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www',
'role' => 'pair',
'pending_message' => "Also add this host so both www and non-www work. {$pointDns}",
'pending_message' => $pendingMessage,
'dns_suffix' => '',
],
};
@@ -663,6 +682,43 @@ class Domains extends Component
return true;
}
/**
* When saved redirect is www/non-www, ensure missing counterparts exist as real domains.
*
* @return array<int, string> newly added domain URLs
*/
protected function syncRedirectDomainPairs(?ServiceApplication $app = null): array
{
$user = auth()->user();
if ($user === null || ! $user->can('update', $this->service)) {
return [];
}
if ($app === null) {
$added = [];
foreach ($this->service->applications as $serviceApp) {
$added = array_merge($added, $this->syncRedirectDomainPairs($serviceApp));
}
return array_values(array_unique($added));
}
$redirect = $this->normalizeRedirect($app->redirect ?? null);
if (! in_array($redirect, ['www', 'non-www'], true)) {
return [];
}
$before = collect($this->splitDomains($app->fqdn))->all();
if (! $this->ensureWwwNonWwwPairsConfigured($app)) {
return [];
}
$app->refresh();
$after = collect($this->splitDomains($app->fqdn));
return $after->reject(fn (string $url) => in_array($url, $before, true))->values()->all();
}
/**
* Persist missing www/non-www counterparts as normal domains (not suggestions).
*
@@ -710,10 +766,13 @@ class Domains extends Component
$this->pendingRedirectServiceApplicationId = $app->id;
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $merged)) {
return false;
}
$this->pendingAction = null;
$this->pendingRedirectServiceApplicationId = null;
return true;
}
@@ -766,6 +825,11 @@ class Domains extends Component
}
$newUrls = $this->splitDomains($normalized);
$pairedUrls = collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all();
$current = collect($this->splitDomains($app->fqdn));
foreach ($newUrls as $url) {
if ($current->contains($url)) {
@@ -785,10 +849,10 @@ class Domains extends Component
}
}
$merged = $current->merge($newUrls)->unique()->values();
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add';
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $merged)) {
return;
}
@@ -802,7 +866,7 @@ class Domains extends Component
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains();
$this->checkUrlsDns($newUrls, (int) $app->id);
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -874,6 +938,7 @@ class Domains extends Component
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = $dnsFailure;
$this->showEditDomainModal = true;
return;
}
@@ -882,7 +947,7 @@ class Domains extends Component
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainListForApp($app, $updated, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $updated)) {
return;
}
@@ -917,7 +982,7 @@ class Domains extends Component
$this->forceSaveDomains = true;
$this->forceRemovePort = true;
if (! $this->saveDomainListForApp($app, $updated, checkDns: false, checkConflicts: false)) {
if (! $this->saveDomainListForApp($app, $updated, checkConflicts: false)) {
return;
}
@@ -970,7 +1035,6 @@ class Domains extends Component
$this->forceAddSuggestedIndex = $index;
$this->editingIndex = $index;
$this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'DNS validation failed.', $dnsFailure);
return;
}
@@ -980,7 +1044,7 @@ class Domains extends Component
$this->pendingAction = 'suggested';
$this->editingIndex = $index;
if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) {
if (! $this->saveDomainListForApp($app, $merged)) {
return;
}
@@ -1035,7 +1099,6 @@ class Domains extends Component
protected function saveDomainListForApp(
ServiceApplication $app,
Collection $domains,
bool $checkDns = true,
bool $checkConflicts = true,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
@@ -1078,25 +1141,6 @@ class Domains extends Component
}
}
if ($checkDns && $domainString && $this->shouldValidateDns()) {
$server = $this->service->server;
if ($server) {
foreach ($this->splitDomains($domainString) as $domain) {
if (! validateDNSEntry($domain, $server)) {
$guidance = dnsMismatchGuidanceMessage(
$this->dnsTargetLabel() ?? serverDnsTargetIp($server) ?? $server->ip,
$this->serverIp ?? serverDnsTargetIp($server) ?? $server->ip,
);
$this->dispatch(
'error',
'Validating DNS failed.',
$guidance
);
}
}
}
}
$warning = sslipDomainWarning($domainString ?? '');
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+111 -18
View File
@@ -37,6 +37,14 @@ class Storage extends Component
public string $file_storage_directory_destination = '';
public string $activeTab = 'volumes';
public int $cachedVolumeCount = 0;
public int $cachedFileCount = 0;
public int $cachedDirectoryCount = 0;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -57,12 +65,18 @@ class Storage extends Component
}
if ($this->resource->getMorphClass() === Application::class) {
if ($this->resource->destination->server->isSwarm()) {
$this->resource->loadMissing('destination.server', 'environment.project');
if ($this->resource->destination?->server?->isSwarm()) {
$this->isSwarm = true;
}
}
$this->refreshStorages();
// Counts only on mount — child All (volumes) / file list load their own payloads.
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
}
public function refreshStoragesFromEvent()
@@ -73,37 +87,110 @@ class Storage extends Component
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
// Avoid loading full volume models onto this parent (child All owns that snapshot).
$this->resource->unsetRelation('persistentStorages');
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->loadFileStorageForActiveTab();
}
public function setActiveTab(string $tab): void
{
if (! in_array($tab, ['volumes', 'files', 'directories'], true)) {
return;
}
$this->activeTab = $tab;
$this->loadFileStorageForActiveTab();
}
private function resolveDefaultTab(): string
{
if ($this->volumeCount > 0) {
return 'volumes';
}
if ($this->fileCount > 0) {
return 'files';
}
if ($this->directoryCount > 0) {
return 'directories';
}
return 'volumes';
}
private function loadVolumeCount(): void
{
$this->cachedVolumeCount = $this->resource->persistentStorages()->count();
}
/**
* Counts only — avoids loading file contents into the Livewire snapshot on the volumes tab.
*/
private function loadFileStorageMetaCounts(): void
{
$this->cachedFileCount = $this->resource->fileStorages()->where('is_directory', false)->count();
$this->cachedDirectoryCount = $this->resource->fileStorages()->where('is_directory', true)->count();
}
/**
* Load full file/directory mounts only for the active tab (content only on files).
*/
private function loadFileStorageForActiveTab(): void
{
if ($this->activeTab === 'volumes') {
// Keep snapshot small while the volumes tab is shown.
$this->fileStorage = collect();
return;
}
$query = $this->resource->fileStorages();
if ($this->activeTab === 'files') {
$query->where('is_directory', false);
} else {
$query->where('is_directory', true);
}
$this->fileStorage = $query->get()->each(function (LocalFileVolume $fs): void {
if ($this->activeTab !== 'files') {
$fs->content = null;
return;
}
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
}
});
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()
{
return $this->fileStorage->where('is_directory', false);
return collect($this->fileStorage)->where('is_directory', false);
}
public function getDirectoriesProperty()
{
return $this->fileStorage->where('is_directory', true);
return collect($this->fileStorage)->where('is_directory', true);
}
public function getVolumeCountProperty()
{
return $this->resource->persistentStorages()->count();
return $this->cachedVolumeCount;
}
public function getFileCountProperty()
{
return $this->files->count();
return $this->cachedFileCount;
}
public function getDirectoryCountProperty()
{
return $this->directories->count();
return $this->cachedDirectoryCount;
}
public function submitPersistentVolume()
@@ -130,12 +217,12 @@ class Storage extends Component
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
$this->clearForm();
$this->activeTab = 'volumes';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -165,11 +252,13 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -198,11 +287,13 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -235,11 +326,13 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'directories';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}