mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +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:
1 parent
dbc6f5e08c
commit
a7a06aa6c8
75 files changed
+3909
-1173
No files matched your search
@@ -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.'.';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user