Merge remote-tracking branch 'origin/next' into coolify-dashboard-ui-refinements

This commit is contained in:
Aditya Tripathi
2026-09-14 11:37:30 +00:00
108 changed files with 2635 additions and 771 deletions
+102
View File
@@ -0,0 +1,102 @@
# Lessons
## Check prior fixes before changing a repeated symptom
- When a reported regression matches a recent fix, inspect that fix and reproduce why it no longer works before adding another workaround.
- Do not claim a redirect or lifecycle root cause from an effects assertion alone. Prove the reported HTTP or browser failure first.
- Preserve SPA navigation when it is a product requirement. Do not replace it with a full-page redirect to mask a deletion race; fix the ordering or state race instead.
## Confirm which surface becomes the modal
- When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal.
- Do not make one child page a modal inside the other child page when the user wants both child URLs removed.
- When the modal itself supplies the title and subtitle, do not repeat page-style section cards inside it. Use a flat input layout and one footer for actions.
- Put destructive actions on the footer's left. Put conversion and the primary Save action on the right, with Save last.
- Do not repeat domain-port guidance in a resource settings modal when domain ports have their own input in the domain editor.
- A flat modal form can still use a bordered summary box for a distinct linked resource, such as the domain count and Manage domains action.
- For compact modal headers, show the descriptive subtitle as hover text on an underlined title instead of adding a second visible line.
- Reuse `x-helper` and the plain `underline underline-offset-4` trigger for title help. Do not use a native `title` tooltip or a dotted underline when the project already has a shared title-tooltip pattern.
## Alpine x-transition + tw-animate-css exit animations flash at the end
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
## Displayed defaults must not become stored overrides
- When an edit form shows an inherited or computed default, trace an unchanged save and a related-field edit through persistence.
- Preserve the inherited state when the displayed value still equals the computed default; store an override only when the user selects a different value.
## Prove regressions against the unchanged baseline
- For a bug fix, run the same regression test before and after the production change. Use a stash when requested so the failure and success come from the exact same test.
## Apply shared domain UX to every supported resource type
- When a user asks for domain-management behavior, inventory every resource that can edit domains before implementation.
- Do not stop at the resource type named in the original report when the requested UX is meant to be consistent across Coolify.
## Verify manual and generated domain paths separately
- Domain regeneration and manual hostname edits must start the same post-save DNS check.
- Add explicit regression coverage for both entry paths across every active domain editor.
## Do not treat a runtime restart as behavior verification
- A healthy restarted container proves only that the process started.
- For a reported UI failure, verify the exact user flow and inspect the resulting persisted state before claiming the fix works.
## Prove the reported live flow before reporting a UI fix
- Do not use unit tests or a healthy process as proof for a reported live UI failure.
- After the user repeats the flow, inspect the exact persisted record, request logs, queue state, and deployed source before stating that it works.
## Start DNS checks only for DNS-relevant edits
- Compare the previous and saved scheme and hostname before a post-save DNS check.
- Do not restart DNS checks for indexing, redirect, path, or internal-port-only changes.
## Include automatically added domains in post-save DNS checks
- Compare the configured domain list before and after Save.
- Start checks for each newly added counterpart, even when the edited domain itself did not change.
## Use one DNS progress pattern
- All DNS check entry points must set the domain badge to the same `checking` state.
- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator.
- Verify the rendered badge uses the spinner slot instead of the default status dot.
## Confirm whether old reports still apply before changing code
- For an old issue, first test the current branch and inspect later fixes. Do not assume that the historical reproduction still needs a new code change.
## Compare routing identity, not complete domain URLs
- Domain-conflict checks must treat `http://host` and `https://host` as the same routing identity.
- Reproduce reports with the exact stored schemes before stating that duplicate detection works.
## Verify reported fixes against the running development app
- When a user asks for before-and-after verification, test the unchanged and fixed production code against the same Jean Run environment.
- Cover each requested interface, such as UI and API, and record the exact URL, response, persisted state, and relevant logs.
## Do not infer that “Pro” means paid
- When the user calls a setting “Pro,” confirm whether it means advanced-user functionality or a subscription entitlement.
- Do not add billing or Cloud-only checks unless the user explicitly requests them.
## Verify compound status layouts visually
- When a status component can render more than one badge, give its root an explicit horizontal flex layout.
- Inspect the real top-bar layout with every conditional badge visible before calling a UI change complete.
## Keep a requested security control at its stated scope
- If the user specifies one team-level redaction flag, do not introduce per-secret policy questions.
- Explain storage constraints as implementation details, then preserve the requested single control.
## Use shared section title helpers in edit modals
- When modal section descriptions should appear on hover, use `x-application.settings-section` instead of a manual heading and visible paragraph.
- Keep text labels for direct actions such as Back up now. Use a standard icon button with a tooltip for familiar secondary actions such as settings.
## Keep modal actions in the footer
- When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action.
## Prefer named API values over numeric sentinels
- When an API option means an unbounded or special mode, expose a clear named value such as `all`.
- Keep an existing numeric sentinel such as `-1` only as a compatibility alias unless the user requests a breaking change.
## Do not auto-heal existing deployments without a request
- When a parser or label fix can apply only after container recreation, keep the change limited to new deployments and later user-initiated redeployments unless the user explicitly asks for live reconciliation.
- Do not add status lookup fallbacks that alter existing deployment behavior when the requested scope is new deployments only.
## Trace image replacements through build stages
- When replacing a container image for development, inspect both Compose services and every development Dockerfile `FROM` stage.
- A successful Compose pull does not prove the application build is free of the old image; validate the complete build dependency chain.
- Do not replace a removed image with a floating `latest` tag. Find the newest stable release tag and pin it consistently in Compose and every Dockerfile stage.
+1
View File
@@ -40,6 +40,7 @@ CHANGELOG.md
/.workspaces
/.superpowers/
/docs/superpowers/plans/
/.ai/todo.md
tests/Browser/Screenshots
tests/v4/Browser/Screenshots
ref
@@ -92,6 +92,11 @@ class UpdateServiceApplicationFromApi
$serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('max_restart_count', $payload)) {
$serviceApplication->max_restart_count = $payload['max_restart_count'];
$serviceApplication->restart_limit_reached = false;
}
if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server;
-3
View File
@@ -6,7 +6,6 @@ use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckMissingDatabaseBackupsJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleMultiplexedConnections;
@@ -91,8 +90,6 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->command('cleanup:database --yes')->daily();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
@@ -2437,13 +2437,12 @@ class ApplicationsController extends Controller
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
])
),
new OA\Parameter(
name: 'show_timestamps',
@@ -2512,13 +2512,12 @@ class DatabasesController extends Controller
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
])
),
new OA\Parameter(
name: 'show_timestamps',
@@ -258,6 +258,7 @@ class ServiceApplicationsController extends Controller
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true),
'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'),
]
)
)
@@ -331,6 +332,7 @@ class ServiceApplicationsController extends Controller
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_force_https_enabled',
'max_restart_count',
];
$validationRules = [
@@ -345,6 +347,7 @@ class ServiceApplicationsController extends Controller
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
'is_force_https_enabled' => 'sometimes|boolean',
'max_restart_count' => 'sometimes|integer|min:0',
];
$validator = Validator::make($payload, $validationRules);
@@ -399,9 +402,12 @@ class ServiceApplicationsController extends Controller
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
])
),
],
responses: [
@@ -448,7 +454,16 @@ class ServiceApplicationsController extends Controller
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
]),
),
],
responses: [
new OA\Response(
@@ -366,7 +366,16 @@ class ServiceDatabasesController extends Controller
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
]),
),
],
responses: [
new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])),
@@ -869,13 +869,12 @@ class ServicesController extends Controller
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
schema: new OA\Schema(oneOf: [
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
new OA\Schema(type: 'string', enum: ['all']),
])
),
new OA\Parameter(
name: 'show_timestamps',
+36 -9
View File
@@ -812,6 +812,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->pull_docker_compose_images();
$this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid;
@@ -915,6 +917,26 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application_deployment_queue->addLogEntry('New container started.');
}
private function pull_docker_compose_images(): void
{
$this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.');
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->mainServer;
$workdir = $this->application->workdir();
$command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable";
} else {
$workdir = $this->workdir;
$command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable");
}
$this->execute_remote_command([
$command,
'hidden' => true,
]);
}
private function deploy_dockerfile_buildpack()
{
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
@@ -2500,15 +2522,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fqdn = $this->preview->fqdn;
}
if (isset($fqdn)) {
$domains = str($fqdn)->explode(',')->map(fn (string $domain) => trim($domain))->filter();
$url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
$fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(',');
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
$domains = str($fqdn)->explode(',')
->map(fn (string $domain) => trim($domain))
->filter()
->filter(fn (string $domain) => isValidDomainUrl($domain));
if ($domains->isNotEmpty()) {
$url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
$fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(',');
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
}
}
if (isset($this->application->git_branch)) {
+2
View File
@@ -73,6 +73,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
// Invalidate cache to ensure fresh data is loaded
invalidate_versions_cache();
CheckTraefikVersionJob::dispatch();
// Only mark new version available if Coolify version actually increased
if (version_compare($latest_version, $current_version, '>')) {
// New version available
@@ -8,6 +8,7 @@ trait TogglesNotificationEvents
'deploymentSuccess',
'deploymentFailure',
'statusChange',
'restartLimitReached',
'backupSuccess',
'backupFailure',
'scheduledTaskSuccess',
+155 -68
View File
@@ -61,6 +61,16 @@ class Domains extends Component
public ?string $editingService = null;
public string $editingIndexing = 'index';
public string $editingRedirect = 'both';
public string $editingOriginalRedirect = 'both';
public bool $editingDomainWasRegenerated = false;
public ?string $editingGeneratedHost = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
public array $domainRows = [];
@@ -132,6 +142,8 @@ class Domains extends Component
return [
'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(),
'editingIndexing' => 'string|required|in:index,noindex',
'editingRedirect' => 'string|required|in:both,www,non-www',
'redirect' => 'string|required|in:both,www,non-www',
'isForceHttpsEnabled' => 'boolean',
'serviceRedirects' => 'array',
@@ -702,41 +714,8 @@ class Domains extends Component
{
$this->authorize('update', $this->application);
$this->isCheckingDns = true;
try {
$server = $this->application->destination?->server;
$skipDns = ! $this->dnsValidationEnabled
|| ! $server
|| $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$reason = ! $this->dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: ($this->application->additional_servers->count() > 0
? 'DNS check skipped for multi-server applications.'
: 'No server available for DNS validation.');
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = $reason;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
continue;
}
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistDomainDnsStatuses();
} finally {
$this->isCheckingDns = false;
foreach ($this->domainRows as $row) {
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
}
}
@@ -748,18 +727,8 @@ class Domains extends Component
return;
}
$server = $this->application->destination?->server;
if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->persistDomainDnsStatuses();
return;
}
$this->applyDnsStatus($index, $server);
$this->persistDomainDnsStatuses();
$row = $this->domainRows[$index];
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
}
protected function applyDnsStatus(int $index, Server $server): void
@@ -1230,6 +1199,34 @@ class Domains extends Component
$this->persistDomainDnsStatuses();
}
/**
* @param array<int, string> $urls
*/
protected function queueUrlsDns(array $urls, ?string $service = null): void
{
foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) {
$checkId = new_public_id();
$this->markUrlsAsChecking([$url], $service, $checkId);
$this->persistDomainDnsStatuses();
try {
CheckDomainDnsJob::dispatch(
$this->application,
$statusKey,
$url,
$this->application->destination?->server,
$this->serverIp,
$checkId,
$this->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$this->markUrlsDnsCheckUnavailable([$url], $service, $checkId);
$this->persistDomainDnsStatuses();
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
}
}
}
protected function shouldValidateDnsForAdd(): bool
{
if (! $this->dnsValidationEnabled) {
@@ -1303,6 +1300,11 @@ class Domains extends Component
}
$this->editingDomainPartsChanged = false;
$this->editingService = $this->domainRows[$index]['service'];
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
$this->editingRedirect = $this->serviceRedirectFor($this->editingService);
$this->editingOriginalRedirect = $this->editingRedirect;
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
@@ -1388,6 +1390,11 @@ class Domains extends Component
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingService = null;
$this->editingIndexing = 'index';
$this->editingRedirect = 'both';
$this->editingOriginalRedirect = 'both';
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
if ($this->pendingAction === 'update') {
@@ -1403,6 +1410,39 @@ class Domains extends Component
$this->updateDomain();
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->application);
if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$server = data_get($this->application, 'destination.server');
if (! $server) {
$this->dispatch('error', 'No server found for this application.');
return;
}
$generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($generatedHost) || $generatedHost === '') {
$this->dispatch('error', 'Could not generate a domain.');
return;
}
$currentHost = (string) ($this->editingDomainParts['host'] ?? '');
$this->editingGeneratedHost = $generatedHost;
$this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.')
? 'www.'.$generatedHost
: $generatedHost;
$this->editingDomainPartsChanged = true;
$this->editingDomainWasRegenerated = true;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
}
public function updateDomain(): void
{
try {
@@ -1422,6 +1462,8 @@ class Domains extends Component
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$this->validateOnly('editingIndexing');
$this->validateOnly('editingRedirect');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) {
@@ -1433,8 +1475,6 @@ class Domains extends Component
$newUrl = $this->splitDomains($normalized)[0];
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$service = $this->editingService;
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
$portOverrides = $this->application->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
@@ -1458,29 +1498,69 @@ class Domains extends Component
return;
}
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) {
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
$this->showEditDomainModal = true;
return;
$replacements = [$oldUrl => $newUrl];
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
if (is_string($oldCounterpart)) {
$counterpartParts = DomainUrlParts::split($oldCounterpart);
$counterpartPort = $this->currentRowPort($oldCounterpart);
if ($counterpartPort !== null) {
$counterpartParts['port'] = (string) $counterpartPort;
}
$counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.')
? 'www.'.$this->editingGeneratedHost
: $this->editingGeneratedHost;
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts);
}
}
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
foreach ($updated->all() as $url) {
$counterpart = $this->wwwCounterpartUrl($url, true);
$counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
$hasCounterpart = filled($counterpartHost) && $updated->contains(
fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost
);
if (filled($counterpart) && ! $hasCounterpart) {
$updated->push($counterpart);
}
}
}
$urlsToCheck = $updated
->reject(fn (string $url): bool => $current->contains(
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->unique()
->values()
->all();
$noindexDomains = $this->application->noindexDomains();
foreach ($replacements as $previousUrl => $replacementUrl) {
$wasNoindexed = $previousUrl === $oldUrl
? $this->editingIndexing === 'noindex'
: $this->application->isDomainNoindexed($previousUrl);
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
if ($wasNoindexed) {
$noindexDomains->push($replacementUrl);
}
}
if ($this->isCompose) {
$allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true);
$existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : [];
$allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]);
$this->application->docker_compose_domains = json_encode($allDomains);
} else {
$this->application->redirect = $this->editingRedirect;
}
$this->pendingAction = 'update';
if (! $this->saveDomainList($updated, $service)) {
if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) {
return;
}
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
if ($wasNoindexed) {
$noindexDomains->push($newUrl);
}
$this->application->setNoindexDomains($noindexDomains);
$this->application->save();
$this->resetDefaultLabels();
$this->forceSaveDomains = false;
@@ -1490,7 +1570,9 @@ class Domains extends Component
$this->dispatch('edit-domain-saved');
$this->dispatch('success', 'Domain updated.');
$this->refreshDomains();
$this->checkUrlsDns([$newUrl], $service);
if ($urlsToCheck !== []) {
$this->queueUrlsDns($urlsToCheck, $service);
}
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -1665,7 +1747,7 @@ class Domains extends Component
$this->resetDefaultLabels();
$this->dispatch('success', 'Redirect updated.');
$this->refreshDomains();
$this->checkUrlsDns($addedDomains);
$this->queueUrlsDns($addedDomains);
$this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) {
handleError($e, $this);
@@ -1762,7 +1844,7 @@ class Domains extends Component
$this->dispatch('success', "Redirect updated for {$serviceName}.");
}
$this->refreshDomains();
$this->checkUrlsDns($addedDomains, $serviceName);
$this->queueUrlsDns($addedDomains, $serviceName);
$this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) {
handleError($e, $this);
@@ -2027,6 +2109,7 @@ class Domains extends Component
Collection $domains,
?string $serviceName = null,
bool $checkConflicts = true,
?Collection $noindexDomains = null,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@@ -2080,6 +2163,10 @@ class Domains extends Component
$this->application->fqdn = $domainString;
}
if ($noindexDomains !== null) {
$this->application->setNoindexDomains($noindexDomains);
}
if ($checkConflicts && ! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) {
@@ -176,6 +176,7 @@ class PreviewDomains extends Component
return;
}
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain);
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
@@ -188,17 +189,75 @@ class PreviewDomains extends Component
$this->preview->domain_port_overrides = $portOverrides ?: null;
}
$this->domainRows[$this->editingIndex]['url'] = $domain;
$this->domainRows[$this->editingIndex]['dns_status'] = 'pending';
$this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.';
$checkId = $dnsRelevantChange ? new_public_id() : null;
if ($dnsRelevantChange) {
$this->domainRows[$this->editingIndex]['dns_status'] = 'checking';
$this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...';
$this->domainRows[$this->editingIndex]['check_id'] = $checkId;
}
$index = $this->editingIndex;
$this->editingIndex = null;
if (! $this->persistDomains()) {
return;
}
$domain = $this->domainRows[$index]['url'];
$this->forceUseUnknownPort = false;
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
$this->dispatch('success', 'Domain updated.');
$this->checkDomainDns($index);
if (! $dnsRelevantChange) {
$this->dispatch('success', 'Domain updated.');
return;
}
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($domain, $this->domainRows[$index]['service']),
$domain,
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
$this->dispatch('success', 'Domain updated. DNS check started.');
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
$this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.');
}
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$server = $this->preview->application->destination?->server;
if (! $server) {
$this->dispatch('error', 'No server found for this preview.');
return;
}
$host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return;
}
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
}
public function cancelEdit(): void
{
$this->editingIndex = null;
$this->editingDomainParts = DomainUrlParts::empty();
$this->resetErrorBag('editingDomainParts.host');
}
public function confirmUseUnknownPort(): void
@@ -263,16 +322,46 @@ class PreviewDomains extends Component
{
$this->authorize('update', $this->preview->application);
foreach (array_keys($this->domainRows) as $index) {
$this->applyDnsCheck($index);
$this->queueDnsCheck($index);
}
$this->persistDnsStatuses();
}
public function checkDomainDns(int $index): void
{
$this->authorize('update', $this->preview->application);
$this->applyDnsCheck($index);
$this->queueDnsCheck($index);
}
private function queueDnsCheck(int $index): void
{
if (! isset($this->domainRows[$index])) {
return;
}
$row = $this->domainRows[$index];
$checkId = new_public_id();
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
$this->persistDnsStatuses();
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($row['url'], $row['service']),
$row['url'],
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
}
}
public function pollDnsChecks(): void
+12 -13
View File
@@ -180,7 +180,8 @@ class BackupEdit extends Component
public function delete($password, $selectedActions = [])
{
$this->authorize('manageBackups', $this->backup->database);
$database = $this->backup->database;
$this->authorize('manageBackups', $database);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
@@ -188,10 +189,10 @@ class BackupEdit extends Component
try {
$server = null;
if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server;
if ($database instanceof ServiceDatabase) {
$server = $database->service->destination->server;
} elseif ($database->destination && $database->destination->server) {
$server = $database->destination->server;
}
$filenames = $this->backup->executions()
@@ -212,9 +213,9 @@ class BackupEdit extends Component
}
}
$database = $this->backup->database;
$backupUuid = $this->backup->uuid;
$this->backup->delete();
$this->skipRender();
auditLog('ui.database.backup_schedule_deleted', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
@@ -222,14 +223,12 @@ class BackupEdit extends Component
'backup_uuid' => $backupUuid,
]);
if ($database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $database;
if ($database instanceof ServiceDatabase) {
return redirectRoute($this, 'project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $serviceDatabase->service->uuid,
'stack_service_uuid' => $serviceDatabase->uuid,
'project_uuid' => $database->service->project()->uuid,
'environment_uuid' => $database->service->environment->uuid,
'service_uuid' => $database->service->uuid,
'stack_service_uuid' => $database->uuid,
]);
} else {
return redirectRoute($this, 'project.database.backup.index', [
+1 -1
View File
@@ -550,7 +550,7 @@ EOD;
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return;
}
+140 -69
View File
@@ -66,6 +66,16 @@ class Domains extends Component
public ?int $editingServiceApplicationId = null;
public string $editingIndexing = 'index';
public string $editingRedirect = 'both';
public string $editingOriginalRedirect = 'both';
public bool $editingDomainWasRegenerated = false;
public ?string $editingGeneratedHost = null;
public bool $showEditDomainModal = false;
public bool $forceSaveDomains = false;
@@ -123,6 +133,8 @@ class Domains extends Component
return [
'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(),
'editingIndexing' => 'string|required|in:index,noindex',
'editingRedirect' => 'string|required|in:both,www,non-www',
'newServiceApplicationId' => 'nullable|integer',
'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www',
@@ -491,35 +503,11 @@ class Domains extends Component
{
$this->authorize('update', $this->service);
$this->isCheckingDns = true;
try {
$server = $this->service->server;
$skipDns = ! $this->dnsValidationEnabled || ! $server;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = ! $this->dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: 'No server available for DNS validation.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
continue;
}
$indexesToCheck[] = $index;
foreach ($this->domainRows as $row) {
$application = $this->findServiceApp((int) $row['service_application_id']);
if ($application) {
$this->queueUrlsDns([$row['url']], $application);
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistAllDomainDnsStatuses();
} finally {
$this->isCheckingDns = false;
}
}
@@ -531,19 +519,11 @@ class Domains extends Component
return;
}
$server = $this->service->server;
if (! $server || ! $this->dnsValidationEnabled) {
$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;
$row = $this->domainRows[$index];
$application = $this->findServiceApp((int) $row['service_application_id']);
if ($application) {
$this->queueUrlsDns([$row['url']], $application);
}
$this->applyDnsStatus($index, $server);
$this->persistAllDomainDnsStatuses();
}
protected function applyDnsStatus(int $index, Server $server): void
@@ -852,7 +832,7 @@ class Domains extends Component
$this->dispatch('configurationChanged');
$this->pruneDomainDnsStatusesToCurrentDomains();
$this->refreshDomains();
$this->checkUrlsDns($addedDomains, $serviceApplicationId);
$this->queueUrlsDns($addedDomains, $app);
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -1201,14 +1181,18 @@ class Domains extends Component
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$app = $this->findServiceApp((int) $this->domainRows[$index]['service_application_id']);
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
$savedPort = ($app?->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($savedPort)) {
$this->editingDomainParts['port'] = (string) $savedPort;
$internalPort = $this->domainRows[$index]['internal_port'] ?? null;
if (filled($internalPort)) {
$this->editingDomainParts['port'] = (string) $internalPort;
}
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
$app = $this->findServiceApp($this->editingServiceApplicationId);
$this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
$this->editingRedirect = $this->serviceRedirectFor($this->editingServiceApplicationId);
$this->editingOriginalRedirect = $this->editingRedirect;
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false;
@@ -1225,6 +1209,11 @@ class Domains extends Component
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = null;
$this->editingIndexing = 'index';
$this->editingRedirect = 'both';
$this->editingOriginalRedirect = 'both';
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false;
@@ -1240,10 +1229,20 @@ class Domains extends Component
return;
}
if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
$editingDomainParts = $this->editingDomainParts;
$editingRow = $this->domainRows[$this->editingIndex];
if (
! ($editingRow['has_port_override'] ?? false)
&& (string) ($editingDomainParts['port'] ?? '') === (string) ($editingRow['internal_port'] ?? '')
) {
$editingDomainParts['port'] = '';
}
if ($this->editingDomainPartsChanged || filled($editingDomainParts['host'] ?? null)) {
$this->editingDomain = DomainUrlParts::compose(...$editingDomainParts);
}
$this->validateOnly('editingDomain');
$this->validateOnly('editingIndexing');
$this->validateOnly('editingRedirect');
$app = $this->findServiceApp($this->editingServiceApplicationId);
if (! $app) {
@@ -1260,8 +1259,6 @@ class Domains extends Component
$newUrl = $this->splitDomains($normalized)[0];
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$current = collect($this->splitDomains($app->fqdn));
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
$portOverrides = $app->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
@@ -1278,31 +1275,50 @@ class Domains extends Component
return;
}
if (! $this->forceSaveEditDns && $this->shouldValidateDns()) {
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = $dnsFailure;
$this->showEditDomainModal = true;
return;
$replacements = [$oldUrl => $newUrl];
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
if (is_string($oldCounterpart)) {
$parts = DomainUrlParts::split($oldCounterpart);
$port = ($app->domain_port_overrides ?? [])[DomainPortOverrides::withoutPort($oldCounterpart)] ?? null;
$parts['port'] = filled($port) ? (string) $port : $parts['port'];
$parts['host'] = str_starts_with(strtolower($parts['host']), 'www.') ? 'www.'.$this->editingGeneratedHost : $this->editingGeneratedHost;
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$parts);
}
}
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
foreach ($updated->all() as $url) {
$counterpart = $this->wwwCounterpartUrl($url, true);
$host = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
if (filled($counterpart) && ! $updated->contains(fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $host)) {
$updated->push($counterpart);
}
}
}
$urlsToCheck = $updated
->reject(fn (string $url): bool => $current->contains(
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->unique()
->values()
->all();
$noindexDomains = $app->noindexDomains();
foreach ($replacements as $previousUrl => $replacementUrl) {
$isNoindexed = $previousUrl === $oldUrl ? $this->editingIndexing === 'noindex' : $app->isDomainNoindexed($previousUrl);
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
if ($isNoindexed) {
$noindexDomains->push($replacementUrl);
}
}
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainListForApp($app, $updated)) {
if (! $this->saveDomainListForApp($app, $updated, noindexDomains: $noindexDomains, redirect: $this->editingRedirect)) {
return;
}
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
if ($wasNoindexed) {
$noindexDomains->push($newUrl);
}
$app->setNoindexDomains($noindexDomains);
$app->save();
$this->cancelEdit();
$this->dispatch('edit-domain-saved');
$this->forceSaveDomains = false;
@@ -1310,7 +1326,9 @@ class Domains extends Component
$this->pendingAction = null;
$this->dispatch('success', 'Domain updated.');
$this->refreshDomains();
$this->checkUrlsDns([$newUrl], (int) $app->id);
if ($urlsToCheck !== []) {
$this->queueUrlsDns($urlsToCheck, $app);
}
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -1484,6 +1502,24 @@ class Domains extends Component
}
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->service);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex]) || ! $this->service->server) {
return;
}
$host = parse_url(generateUrl(server: $this->service->server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return;
}
$this->editingGeneratedHost = $host;
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
$this->editingDomainPartsChanged = true;
$this->editingDomainWasRegenerated = true;
}
/**
* @param Collection<int, string> $domains
*/
@@ -1492,6 +1528,8 @@ class Domains extends Component
Collection $domains,
bool $checkConflicts = true,
bool $checkPorts = true,
?Collection $noindexDomains = null,
?string $redirect = null,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@@ -1506,6 +1544,12 @@ class Domains extends Component
}
$app->fqdn = $domainString;
if ($noindexDomains !== null) {
$app->setNoindexDomains($noindexDomains);
}
if ($redirect !== null) {
$app->redirect = $redirect;
}
if ($checkConflicts && ! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $app);
@@ -1605,6 +1649,33 @@ class Domains extends Component
$this->persistAllDomainDnsStatuses();
}
/**
* @param array<int, string> $urls
*/
protected function queueUrlsDns(array $urls, ServiceApplication $application): void
{
foreach (array_unique($urls) as $url) {
$checkId = new_public_id();
$this->markUrlsAsChecking([$url], (int) $application->id, $checkId);
$this->persistAllDomainDnsStatuses();
try {
CheckDomainDnsJob::dispatch(
$application,
$url,
$url,
$this->service->server,
$this->serverIp,
$checkId,
);
} catch (\Throwable) {
$this->markUrlsDnsCheckUnavailable([$url], (int) $application->id, $checkId);
$this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
}
}
}
protected function shouldValidateDns(): bool
{
return $this->dnsValidationEnabled && $this->service->server !== null;
+56 -3
View File
@@ -20,7 +20,7 @@ class Index extends Component
public ?Service $service = null;
public ?ServiceApplication $serviceApplication = null;
public ServiceApplication|ServiceDatabase|null $serviceApplication = null;
public ?ServiceDatabase $serviceDatabase = null;
@@ -28,6 +28,8 @@ class Index extends Component
public ?string $currentRoute = null;
public bool $embedded = false;
public array $parameters;
public array $query;
@@ -82,6 +84,8 @@ class Index extends Component
public bool $isStripprefixEnabled = false;
public mixed $maxRestartCount = 10;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages'];
protected $rules = [
@@ -93,16 +97,39 @@ class Index extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
'maxRestartCount' => 'integer|min:0',
// Application-specific rules
'fqdn' => 'nullable',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function mount(?ServiceApplication $serviceApplication = null)
{
public function mount(
ServiceApplication|ServiceDatabase|null $serviceApplication = null,
bool $embedded = false,
) {
try {
$this->embedded = $embedded;
$this->services = collect([]);
if ($serviceApplication instanceof ServiceDatabase) {
$this->service = $serviceApplication->service;
$this->authorize('view', $this->service);
$this->parameters = [
'project_uuid' => $this->service->environment->project->uuid,
'environment_uuid' => $this->service->environment->uuid,
'service_uuid' => $this->service->uuid,
'stack_service_uuid' => $serviceApplication->uuid,
];
$this->query = request()->query();
$this->currentRoute = 'project.service.index';
$this->serviceDatabase = $serviceApplication;
$this->serviceApplication = null;
$this->resourceType = 'database';
$this->initializeDatabaseProperties();
$this->s3s = currentTeam()->s3s;
return;
}
if ($serviceApplication) {
$this->service = $serviceApplication->service;
$this->authorize('view', $this->service);
@@ -113,6 +140,7 @@ class Index extends Component
'stack_service_uuid' => $serviceApplication->uuid,
];
$this->query = request()->query();
$this->currentRoute = 'project.service.index';
$this->serviceApplication = $serviceApplication;
$this->resourceType = 'application';
$this->initializeApplicationProperties();
@@ -134,6 +162,12 @@ class Index extends Component
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) {
return redirect()->route(
'project.service.configuration',
collect($this->parameters)->except('stack_service_uuid')->all(),
);
}
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
$this->serviceApplication = $service;
@@ -385,6 +419,10 @@ class Index extends Component
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) {
$this->serviceApplication->restart_limit_reached = false;
}
$this->serviceApplication->max_restart_count = $this->maxRestartCount;
} else {
$this->humanName = $this->serviceApplication->human_name;
$this->description = $this->serviceApplication->description;
@@ -394,9 +432,24 @@ class Index extends Component
$this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true);
$this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 10;
}
}
public function saveMaxRestartCount(): void
{
$this->authorize('update', $this->serviceApplication);
$validated = $this->validate([
'maxRestartCount' => 'integer|min:0',
]);
$this->serviceApplication->update([
'max_restart_count' => $validated['maxRestartCount'],
'restart_limit_reached' => false,
]);
$this->dispatch('success', 'Max restart count saved.');
}
public function instantSaveApplication()
{
try {
+15 -6
View File
@@ -131,6 +131,12 @@ class GetLogs extends Component
$this->streamLogs = ! $this->streamLogs;
}
public function showAllLogs(): void
{
$this->numberOfLines = -1;
$this->getLogs(true);
}
public function getLogs($refresh = false)
{
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
@@ -149,22 +155,25 @@ class GetLogs extends Component
if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) {
return;
}
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
$logTail = $this->numberOfLines === -1 ? 'all' : $this->numberOfLines;
if ($logTail !== 'all' && ($logTail <= 0 || is_null($logTail))) {
$this->numberOfLines = 1000;
$logTail = $this->numberOfLines;
}
if ($this->numberOfLines > self::MAX_LOG_LINES) {
if ($logTail !== 'all' && $logTail > self::MAX_LOG_LINES) {
$this->numberOfLines = self::MAX_LOG_LINES;
$logTail = $this->numberOfLines;
}
if ($this->container) {
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
$command = "docker service logs -n {$this->numberOfLines} -t {$this->container}";
$command = "docker service logs -n {$logTail} -t {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
} else {
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
$command = "docker logs -n {$logTail} -t {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
@@ -172,13 +181,13 @@ class GetLogs extends Component
}
} else {
if ($this->server->isSwarm()) {
$command = "docker service logs -n {$this->numberOfLines} {$this->container}";
$command = "docker service logs -n {$logTail} {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
} else {
$command = "docker logs -n {$this->numberOfLines} {$this->container}";
$command = "docker logs -n {$logTail} {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
+3 -1
View File
@@ -98,6 +98,8 @@ trait ResolvesResource
*/
protected function normalizeMcpLogLines(mixed $lines): int
{
return normalizeLogLines($lines, default: 100, max: 500);
$lines = normalizeLogLines($lines, default: 100, max: 500);
return is_int($lines) && $lines > 0 ? $lines : 100;
}
}
+6 -1
View File
@@ -132,6 +132,8 @@ class Application extends BaseModel
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_DOCKER_COMPOSE_COLLECTION_ALIASES = 256;
private static $parserVersion = '5';
protected $fillable = [
@@ -2070,7 +2072,10 @@ class Application extends BaseModel
public function oldRawParser()
{
try {
$yaml = Yaml::parse($this->docker_compose_raw);
$yaml = Yaml::parse(
$this->docker_compose_raw,
maxAliasesForCollections: self::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES,
);
} catch (\Exception $e) {
throw new RuntimeException($e->getMessage());
}
+3 -3
View File
@@ -1590,7 +1590,7 @@ class Service extends BaseModel
Storage::disk('local')->delete("tmp/{$filename}");
$commands[] = "cd $workdir";
$commands[] = 'rm -f .env || true';
$environmentFilename = new_public_id().'.env.tmp';
$envs = collect([]);
@@ -1621,10 +1621,10 @@ class Service extends BaseModel
$envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}");
}
if ($envs->count() === 0) {
$commands[] = 'touch .env';
$commands[] = "touch {$environmentFilename} && mv {$environmentFilename} .env";
} else {
$envs_base64 = base64_encode($envs->implode("\n"));
$commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null";
$commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFilename} > /dev/null && mv {$environmentFilename} .env";
}
instant_remote_process($commands, $this->server);
+1 -1
View File
@@ -17,7 +17,7 @@ class ValidS3BucketName implements ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! ValidationPatterns::isValidS3BucketName($value)) {
$fail('The :attribute must be a valid S3 bucket name: 3-63 lowercase letters, numbers, dots, or hyphens; start and end with a letter or number; no consecutive dots, dot-hyphen pairs, or IP address format.');
$fail('The :attribute must be a valid S3 bucket name: 3-63 letters, numbers, dots, or hyphens; start and end with a letter or number; no consecutive dots, dot-hyphen pairs, or IP address format.');
}
}
}
+1
View File
@@ -34,6 +34,7 @@ class DomainPortOverrides
$normalizedDomains = collect(explode(',', $fqdn))
->map(fn (string $domain): string => trim($domain))
->filter()
->filter(fn (string $domain): bool => isValidDomainUrl($domain))
->map(function (string $domain) use ($existingOverrides): array {
$portlessDomain = self::withoutPort($domain);
$parts = DomainUrlParts::split($domain);
+9
View File
@@ -46,6 +46,15 @@ class DomainUrlParts
];
}
public static function hasDnsRelevantChange(string $oldUrl, string $newUrl): bool
{
$old = self::split($oldUrl);
$new = self::split($newUrl);
return $old['scheme'] !== $new['scheme']
|| strtolower($old['host']) !== strtolower($new['host']);
}
/**
* @return array{scheme: string, host: string, port: string, path: string}
*/
+4 -4
View File
@@ -96,11 +96,11 @@ class ValidationPatterns
/**
* Pattern for S3 bucket names.
*
* Bucket names must be 3-63 lowercase characters, start and end with a
* letter or digit, and contain only lowercase letters, digits, dots, and
* hyphens. Additional semantic checks live in isValidS3BucketName().
* Bucket names must be 3-63 characters, start and end with a letter or
* digit, and contain only letters, digits, dots, and hyphens. Uppercase
* letters remain supported for legacy and S3-compatible buckets.
*/
public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[a-z0-9][a-z0-9.-]*[a-z0-9]\z/';
public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]\z/';
/**
* Pattern for Docker-compatible environment variable keys.
+11 -4
View File
@@ -1571,10 +1571,17 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
}
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string
{
if ($lines === 'all') {
return 'all';
}
$lines = filter_var($lines, FILTER_VALIDATE_INT);
if ($lines === false || $lines <= 0) {
if ($lines === -1) {
return 'all';
}
if ($lines === false || $lines < -1) {
return $default;
}
@@ -1586,7 +1593,7 @@ function parseLogTimestampFlag(mixed $showTimestamps): bool
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string
{
$command = "docker logs -n {$lines}";
if ($server->isSwarm()) {
@@ -1600,7 +1607,7 @@ function buildContainerLogsCommand(Server $server, string $container_id, int $li
return "{$command} ".escapeshellarg($container_id).' 2>&1';
}
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string
{
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
$output = removeAnsiColors($output);
+72 -63
View File
@@ -68,6 +68,52 @@ function isValidDomainUrl(string $url): bool
return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
}
function domainConflictKey(string $domain): string
{
$domain = str($domain)->endsWith('/')
? str($domain)->beforeLast('/')->toString()
: $domain;
return preg_replace('#^https?://#i', '', $domain) ?? $domain;
}
/**
* @return Collection<int, array{domain: string, service_name: ?string}>
*/
function applicationDomainEntries(Application $application): Collection
{
$entries = collect(explode(',', (string) $application->fqdn))
->filter(fn ($domain) => $domain !== '')
->map(fn ($domain) => [
'domain' => str($domain)->finish('/')->beforeLast('/')->toString(),
'service_name' => null,
]);
if ($application->build_pack !== 'dockercompose' || empty($application->docker_compose_domains)) {
return $entries->values();
}
$composeDomains = json_decode($application->docker_compose_domains, true);
if (! is_array($composeDomains)) {
return $entries->values();
}
foreach ($composeDomains as $serviceName => $domainConfig) {
foreach (explode(',', (string) data_get($domainConfig, 'domain')) as $domain) {
if ($domain === '') {
continue;
}
$entries->push([
'domain' => str($domain)->finish('/')->beforeLast('/')->toString(),
'service_name' => (string) $serviceName,
]);
}
}
return $entries->values();
}
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
{
$conflicts = [];
@@ -80,8 +126,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
if ($resource) {
if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') {
$domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
$domains = collect($domains);
$domains = applicationDomainEntries($resource)->pluck('domain');
} else {
$domains = collect($resource->fqdns);
}
@@ -96,7 +141,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
$domain = str($domain)->beforeLast('/');
}
return str($domain);
return domainConflictKey((string) $domain);
});
// Filter applications by team if we have a current team
@@ -108,13 +153,9 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
}
$apps = $appsQuery->get();
foreach ($apps as $app) {
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
foreach (applicationDomainEntries($app) as $domainEntry) {
$naked_domain = $domainEntry['domain'];
if ($domains->contains(domainConflictKey($naked_domain))) {
if (data_get($resource, 'uuid')) {
if ($resource->uuid !== $app->uuid) {
$conflicts[] = [
@@ -122,16 +163,18 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
];
}
} elseif ($domain) {
} else {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
];
}
}
@@ -153,7 +196,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
if ($domains->contains(domainConflictKey($naked_domain))) {
if (data_get($resource, 'uuid')) {
if ($resource->uuid !== $app->uuid) {
$conflicts[] = [
@@ -185,7 +228,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
if ($domains->contains(domainConflictKey($naked_domain))) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => 'Coolify Instance',
@@ -219,7 +262,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
$domain = str($domain)->beforeLast('/');
}
return str($domain);
return domainConflictKey((string) $domain);
});
$applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']);
@@ -231,51 +274,17 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
}
foreach ($applications as $app) {
if (! is_null($app->fqdn)) {
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
];
}
}
}
if ($app->build_pack === 'dockercompose' && ! empty($app->docker_compose_domains)) {
$dockerComposeDomains = json_decode($app->docker_compose_domains, true);
if (is_array($dockerComposeDomains)) {
foreach ($dockerComposeDomains as $serviceName => $domainConfig) {
$domainValue = data_get($domainConfig, 'domain');
if (empty($domainValue)) {
continue;
}
$list_of_domains = collect(explode(',', $domainValue))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'service_name' => $serviceName,
'message' => "Domain $naked_domain is already in use by application '{$app->name}' (service: {$serviceName})",
];
}
}
}
foreach (applicationDomainEntries($app) as $domainEntry) {
$naked_domain = $domainEntry['domain'];
if ($domains->contains(domainConflictKey($naked_domain))) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
];
}
}
}
@@ -290,7 +299,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
if ($domains->contains(domainConflictKey($naked_domain))) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name ?? 'Unknown Service',
@@ -310,7 +319,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
if ($domains->contains(domainConflictKey($naked_domain))) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => 'Coolify Instance',
+1 -1
View File
@@ -2546,7 +2546,7 @@ function serviceParser(Service $resource): Collection
projectName: $resource->project()->name,
resourceName: $resource->name,
type: 'service',
subType: $isDatabase ? 'database' : 'application',
subType: $savedService instanceof ServiceDatabase ? 'database' : 'application',
subId: $savedService->id,
subName: $savedService->human_name ?? $savedService->name,
environment: $resource->environment->name,
+2 -2
View File
@@ -162,7 +162,7 @@ services:
networks:
- coolify
# maxio-init:
# image: minio/mc:latest
# image: quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z
# pull_policy: always
# container_name: coolify-maxio-init
# restart: no
@@ -182,7 +182,7 @@ services:
# networks:
# - coolify
minio-init:
image: minio/mc:latest
image: quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z
pull_policy: always
container_name: coolify-minio-init
restart: no
+1 -1
View File
@@ -195,7 +195,7 @@ services:
minio-init:
profiles: ["minio"]
image: minio/mc:latest
image: quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z
pull_policy: always
restart: "no"
depends_on:
+1 -1
View File
@@ -150,7 +150,7 @@ services:
networks:
- coolify
minio-init:
image: minio/mc:latest
image: quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z
pull_policy: always
container_name: coolify-minio-init
restart: no
+1 -5
View File
@@ -15,11 +15,7 @@ ARG NIXPACKS_VERSION=1.41.0
ARG RAILPACK_VERSION=0.23.0
# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
ARG MISE_VERSION=2026.3.17
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
FROM minio/mc:${MINIO_VERSION} AS minio-client
FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client
FROM ${BASE_IMAGE} AS base
+1 -3
View File
@@ -1,8 +1,6 @@
# Versions
# https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine
ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
# https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2025.7.0
# https://www.postgresql.org/support/versioning/
@@ -14,7 +12,7 @@ ARG NGINX_VERSION=1.31.2-r1
# =================================================================
# Get MinIO client
# =================================================================
FROM minio/mc:${MINIO_VERSION} AS minio-client
FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client
# =================================================================
# Final Stage: Production image
+1 -3
View File
@@ -1,8 +1,6 @@
# Versions
# https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine
ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
# https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2026.7.3
# https://www.postgresql.org/support/versioning/
@@ -63,7 +61,7 @@ RUN npm run build
# =================================================================
# Stage 3: Get MinIO client
# =================================================================
FROM minio/mc:${MINIO_VERSION} AS minio-client
FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client
# =================================================================
# Final Stage: Production image
+8
View File
@@ -17290,6 +17290,14 @@
"null"
]
},
"max_restart_count": {
"description": "Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.",
"type": [
"integer",
"null"
],
"minimum": 0
},
"is_force_https_enabled": {
"type": [
"boolean",
+4
View File
@@ -10944,6 +10944,10 @@ paths:
type: [boolean, 'null']
is_stripprefix_enabled:
type: [boolean, 'null']
max_restart_count:
description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'
type: [integer, 'null']
minimum: 0
is_force_https_enabled:
type: [boolean, 'null']
type: object
+1 -1
View File
@@ -13,7 +13,7 @@
"version": "1.0.18"
},
"sentinel": {
"version": "0.0.22"
"version": "1.0.1"
}
},
"traefik": {
+38 -33
View File
@@ -5031,42 +5031,14 @@ a.command-palette-item:focus-visible {
}
.data-table-row.service-domains-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.data-table-row.service-domains-overview-grid > :first-child {
grid-column: 1 / -1;
}
.service-domain-detail {
justify-content: space-between;
gap: 0.5rem;
}
.service-domain-detail-label {
display: inline;
color: var(--coollabs-fg-dim);
}
}
@container service-domains (max-width: 600px) {
.data-table-row.service-domains-overview-grid {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas: "domain dns actions" "summary summary summary";
gap: 0.625rem 0.75rem;
padding: 0.875rem;
padding: 0.75rem;
}
.data-table-row.service-domains-overview-grid > :first-child {
grid-column: 1 / -1;
}
.data-table-row.service-domains-overview-grid > :first-child a,
.data-table-row.service-domains-overview-grid > :first-child span[title] {
overflow: visible;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
grid-area: domain;
}
.service-domain-detail,
@@ -5076,7 +5048,7 @@ a.command-palette-item:focus-visible {
.service-domain-mobile-summary {
display: flex;
grid-column: 1 / -1;
grid-area: summary;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem 0.75rem;
@@ -5092,10 +5064,43 @@ a.command-palette-item:focus-visible {
}
.service-domain-dns {
grid-area: dns;
justify-self: end;
}
.service-domain-actions {
grid-area: actions;
justify-self: end;
}
}
@container service-domains (max-width: 600px) {
.data-table-row.service-domains-overview-grid {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: "domain domain" "summary summary" "dns actions";
gap: 0.625rem 0.75rem;
padding: 0.875rem;
}
.data-table-row.service-domains-overview-grid > :first-child {
grid-area: domain;
}
.data-table-row.service-domains-overview-grid > :first-child a,
.data-table-row.service-domains-overview-grid > :first-child span[title] {
overflow: visible;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
}
.service-domain-dns {
grid-area: dns;
justify-self: start;
}
.service-domain-actions {
grid-area: actions;
justify-self: end;
}
+4
View File
@@ -137,6 +137,10 @@
@apply bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white;
}
@utility view-toggle {
@apply flex h-8 items-center rounded-lg border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-white/[0.06];
}
@utility loading-indicator {
@apply text-coollabs dark:text-warning;
}
@@ -47,7 +47,7 @@
{{ $conflict['resource_name'] }}
</a>
@endif
({{ $conflict['resource_type'] }})
({{ $conflict['resource_type'] }}@if (filled($conflict['service_name'] ?? null)): {{ $conflict['service_name'] }}@endif)
</div>
</li>
@endforeach
@@ -1,5 +1,6 @@
@props([
'title' => 'Are you sure?',
'subtitle' => null,
'buttonTitle' => 'Open Modal',
'isErrorButton' => false,
'isHighlightedButton' => false,
@@ -60,7 +61,19 @@
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-wrap! sm:flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
<div class="min-w-0 flex-1 py-0.5">
@if ($subtitle)
<h3>
<x-helper :helper="$subtitle" :label="'More information about '.$title">
<x-slot:trigger>
<span class="underline underline-offset-4">{{ $title }}</span>
</x-slot:trigger>
</x-helper>
</h3>
@else
<h3 class="truncate">{{ $title }}</h3>
@endif
</div>
@isset($headerActions)
<div class="order-3 w-full sm:order-none sm:w-auto flex shrink-0 items-center gap-2">
{{ $headerActions }}
@@ -78,6 +91,12 @@
style="-webkit-overflow-scrolling: touch;">
{{ $slot }}
</div>
@isset($footer)
<footer
class="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-neutral-200 px-4 py-3 dark:border-white/[0.08]">
{{ $footer }}
</footer>
@endisset
</div>
</div>
</div>
@@ -8,7 +8,7 @@
{{--
Shared section heading: a 14px title, an optional 11px muted subtitle, and an
optional right-aligned action. Pass `href` for the default ghost "View all"
optional right-aligned action. Pass `href` for the default "View all"
button, or an `<x-slot:actions>` for a custom control.
--}}
<div {{ $attributes->merge(['class' => 'mb-3 min-w-0']) }}>
@@ -20,7 +20,7 @@
<div class="shrink-0">{{ $actions }}</div>
@elseif (filled($href))
<a href="{{ $href }}" {{ wireNavigate() }}
class="group inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 text-[12px] font-medium text-neutral-600 transition-[color,background-color,transform] duration-100 ease-out hover:bg-neutral-100 hover:text-black active:scale-[0.97] dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-fg-dim dark:hover:bg-white/[0.1] dark:hover:text-fg">
class="button group">
{{ $actionLabel }}
@if ($icon)
<x-reicon :name="$icon" class="size-3 opacity-70 transition-transform duration-150 ease-out group-hover:translate-x-0.5" />
@@ -3,25 +3,6 @@
'serviceDatabase',
])
@php
$items = [
[
'label' => 'General',
'route' => 'project.service.index',
'icon' => 'settings',
'active' => request()->routeIs('project.service.index'),
],
[
'label' => 'Advanced',
'route' => 'project.service.index.advanced',
'icon' => 'grid',
'active' => request()->routeIs('project.service.index.advanced'),
],
];
$items = array_values(array_filter($items, fn (array $item): bool => $item['visible'] ?? true));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@@ -32,16 +13,10 @@
<span class="menu-item-label">Back to service</span>
</a>
@foreach ($items as $item)
<a @class([
'menu-item',
'menu-item-active' => $item['active'],
])
@if ($item['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($item['route'], $item['parameters'] ?? $parameters) }}">
<x-reicon :name="$item['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $item['label'] }}</span>
</a>
@endforeach
<a class="menu-item menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.index', $parameters) }}">
<x-reicon name="settings" class="menu-item-icon" />
<span class="menu-item-label">General</span>
</a>
</nav>
</aside>
@@ -14,7 +14,7 @@
</div>
<div
class="flex h-9 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="view-toggle w-fit">
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
+3 -1
View File
@@ -9,6 +9,7 @@
@auth
<div x-data="{
open: false,
hasSidebarPreference: localStorage.getItem('sidebarCollapsed') !== null,
userCollapsed: localStorage.getItem('sidebarCollapsed') === 'true',
autoCollapse: localStorage.getItem('sidebarAutoCollapse') !== 'false',
hasSecondBar: false,
@@ -25,7 +26,7 @@
},
targetCollapsed() {
this.hasSecondBar = !!document.querySelector('.application-settings-navigation');
return this.userCollapsed || (this.autoCollapse && this.hasSecondBar);
return this.hasSidebarPreference ? this.userCollapsed : (this.autoCollapse && this.hasSecondBar);
},
applyCollapsed(animate) {
const target = this.targetCollapsed();
@@ -41,6 +42,7 @@
},
toggleSidebar() {
this.collapsed = !this.collapsed;
this.hasSidebarPreference = true;
this.userCollapsed = this.collapsed;
localStorage.setItem('sidebarCollapsed', this.userCollapsed);
},
@@ -16,14 +16,27 @@
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
openEditDomain() {
this.editingServiceLabel = $wire.editingService || '';
openEditDomain(index, domain, parts, service, indexing, redirect) {
if (index !== undefined) {
$wire.set('editingIndex', index, false);
$wire.set('editingDomain', domain, false);
$wire.set('editingDomainParts', parts, false);
$wire.set('editingDomainPartsChanged', false, false);
$wire.set('editingService', service, false);
$wire.set('editingIndexing', indexing, false);
$wire.set('editingRedirect', redirect, false);
$wire.set('editingOriginalRedirect', redirect, false);
$wire.set('editingDomainWasRegenerated', false, false);
$wire.set('editingGeneratedHost', null, false);
}
this.editingServiceLabel = service ?? $wire.editingService ?? '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
},
closeEditDomain() {
closeEditDomain(discardDraft = true) {
this.modalOpen = false;
this.editingServiceLabel = '';
if (discardDraft) this.$wire.cancelEdit();
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
@@ -33,7 +46,7 @@
},
}"
@open-edit-domain.window="openEditDomain()"
@edit-domain-saved.window="closeEditDomain()">
@edit-domain-saved.window="closeEditDomain(false)">
@if ($hasDnsChecksInProgress)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
@@ -77,7 +90,7 @@
</div>
@endif
@can('update', $application)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
</x-forms.button>
@@ -316,33 +329,20 @@
</x-callout>
@endif
@php
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
@endphp
@if ($editingRow && ! $labelsAreWritable)
@unless ($labelsAreWritable)
@can('update', $application)
@php
$editingKey = hash('sha256', $editingRow['url'].'|'.($editingRow['service'] ?? ''));
$editingRedirectKey = $isCompose ? $this->serviceRedirectWireKey($editingRow['service']) : null;
$editingRedirectProperty = $isCompose ? 'serviceRedirects.'.$editingRedirectKey : 'redirect';
@endphp
<div wire:key="editing-application-domain-settings-{{ $editingKey }}"
<div
class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<x-forms.listbox id="application-domain-indexing-{{ $editingKey }}"
label="Search engine indexing" :wire="false" preserveValue
:value="$application->isDomainNoindexed($editingRow['url']) ? 'noindex' : 'index'"
onChange="toggleNoindexDomain" :onChangeArgs="[$editingRow['url']]" portal
<x-forms.listbox id="editingIndexing"
htmlId="application-domain-indexing" label="Search engine indexing" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="application-domain-direction-{{ $editingKey }}"
label="www redirect" :wire="false" preserveValue
:value="$isCompose ? ($serviceRedirects[$editingRedirectKey] ?? 'both') : $redirect"
:x-effect="'value = $wire.get('.json_encode($editingRedirectProperty).')'"
<x-forms.listbox id="editingRedirect"
htmlId="application-domain-direction" label="www redirect"
:helper="$isCompose ? 'Applies to all domains for this Compose service.' : 'Applies to all domains for this application.'"
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
:onChangeArgs="$isCompose ? [$editingRow['service']] : []" portal
portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
@@ -350,12 +350,16 @@
]" />
</div>
@endcan
@endif
@endunless
</div>
<div data-testid="domain-settings-footer"
class="shrink-0 border-t border-neutral-200 pt-4 dark:border-white/10">
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<x-forms.button type="button" wire:click="regenerateEditingDomain"
wire:target="regenerateEditingDomain">
Regenerate hostname
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">
Continue
@@ -32,6 +32,7 @@
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>
@php
$domainCount = 0;
$primaryDomain = null;
if ($buildPack === 'dockercompose') {
$composeDomains = $application->docker_compose_domains
? json_decode($application->docker_compose_domains, true)
@@ -41,35 +42,57 @@
$domainString = data_get($serviceDomain, 'domain');
if (filled($domainString)) {
$domainCount += countDomains($domainString);
$primaryDomain ??= collect(explode(',', $domainString))
->map(fn ($domain) => trim($domain))
->first(fn ($domain) => filled($domain));
}
}
}
} elseif (filled($fqdn)) {
$domainCount = countDomains($fqdn);
$primaryDomain = collect(explode(',', $fqdn))
->map(fn ($domain) => trim($domain))
->first(fn ($domain) => filled($domain));
}
$additionalDomainCount = max(0, $domainCount - 1);
@endphp
<div class="flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50/60 px-4 py-3 dark:border-white/[0.07] dark:bg-white/[0.05]">
<div class="flex min-w-0 flex-1 items-center gap-3">
@php
$applicationDomainsUrl = route('project.application.domains', [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
]);
@endphp
<div class="group relative flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50/60 px-4 py-3 transition-colors hover:bg-neutral-100 focus-within:ring-2 focus-within:ring-coollabs/40 dark:border-white/[0.07] dark:bg-white/[0.05] dark:hover:bg-white/[0.08] dark:focus-within:ring-warning/40">
<a class="flex min-w-0 flex-1 items-center gap-3 after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
aria-label="{{ $domainCount > 0 ? 'Manage application domains' : 'Add an application domain' }}"
href="{{ $applicationDomainsUrl }}" {{ wireNavigate() }}>
<div class="flex size-9 shrink-0 items-center justify-center rounded-md bg-neutral-200/70 text-neutral-600 dark:bg-white/[0.07] dark:text-fg-dim">
<x-reicon name="globe" class="size-4" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-black dark:text-fg">
{{ $domainCount }} configured {{ Str::plural('domain', $domainCount) }}
@if ($primaryDomain)
<span class="block truncate">{{ $primaryDomain }}</span>
@else
No public domain configured
@endif
</p>
<p class="text-xs text-neutral-500 dark:text-fg-dim">
Domains, DNS checks, and redirect settings
@if ($additionalDomainCount > 0)
+{{ $additionalDomainCount }} more {{ Str::plural('domain', $additionalDomainCount) }}
@elseif ($domainCount === 0)
Make this application available from a URL
@else
Manage DNS checks and redirect settings
@endif
</p>
</div>
</div>
<a class="icon-button ml-auto shrink-0" title="Manage domains"
aria-label="Manage domains"
href="{{ route('project.application.domains', [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
]) }}" {{ wireNavigate() }}>
<x-reicon name="settings" class="size-4" />
</a>
<a class="button relative z-10 ml-auto shrink-0" aria-label="{{ $domainCount > 0 ? 'Manage application domains' : 'Add an application domain' }}"
href="{{ $applicationDomainsUrl }}" {{ wireNavigate() }}>
{{ $domainCount > 0 ? 'Manage domains' : 'Add domain' }}
<x-reicon name="arrow-right" class="size-4" />
</a>
</div>
</section>
@@ -25,6 +25,10 @@
: $redirect;
$isNoindexed = $application->isDomainNoindexed($row['url']);
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
$editingParts = \App\Support\DomainUrlParts::split($row['url']);
if ($row['has_port_override'] ?? false) {
$editingParts['port'] = (string) $row['internal_port'];
}
@endphp
<div wire:key="domain-row-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
@@ -117,6 +121,11 @@
@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]" />
@elseif ($row['dns_status'] === 'checking')
<x-status-badge dynamic :title="$row['dns_message']">
<x-loading compact aria-label="Checking DNS" />
<span class="truncate">Checking DNS...</span>
</x-status-badge>
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@@ -129,11 +138,7 @@
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5"
wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-reicon name="refresh" class="size-3.5" />
</button>
@unless ($labelsAreWritable)
@if ($isSuggested)
@@ -147,7 +152,8 @@
</x-forms.button>
@endif
@else
<button type="button" wire:click="startEdit({{ $index }})"
<button type="button"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))"
class="icon-button shrink-0"
title="Domain settings" aria-label="Settings for {{ $publicUrl }}">
<x-reicon name="settings" class="size-3.5" />
@@ -7,16 +7,26 @@
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
&& !$wire.showPortWarningModal;
},
openEditDomain() {
editingServiceLabel: '',
openEditDomain(index, domain, parts, service) {
if (index !== undefined) {
$wire.set('editingIndex', index, false);
$wire.set('editingDomainParts', parts, false);
}
this.editingServiceLabel = service || '';
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editOpen = true;
this.$nextTick(() => this.$refs.editForm.querySelector('input[required]')?.focus());
},
closeEditDomain() { this.editOpen = false; this.editingDomainBaseline = null; },
closeEditDomain(discardDraft = true) {
this.editOpen = false;
this.editingDomainBaseline = null;
if (discardDraft) this.$wire.cancelEdit();
},
matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); },
}"
@open-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) openEditDomain()"
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain()"
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain(false)"
@keydown.escape.window="if (editOpen && !$wire.showPortWarningModal) closeEditDomain()">
@if (collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'))
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@@ -31,7 +41,7 @@
@endif
@can('update', $preview->application)
@if (count($domainRows) > 0)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
</x-forms.button>
@@ -100,6 +110,10 @@
default => 'DNS unknown',
};
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
$editingParts = \App\Support\DomainUrlParts::split($row['url']);
if ($row['has_port_override'] ?? false) {
$editingParts['port'] = (string) $row['internal_port'];
}
@endphp
<div wire:key="preview-domain-{{ md5(($row['service'] ?? '') . $row['url']) }}" x-show="matchesDomainSearch(@js(($row['service'] ?? '').' '.$row['url']))" class="env-table-item">
<div class="data-table-row service-domains-overview-grid">
@@ -152,7 +166,14 @@
</div>
<div class="service-domain-dns flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
@if ($row['dns_status'] === 'checking')
<x-status-badge dynamic :title="$row['dns_message']">
<x-loading compact aria-label="Checking DNS" />
<span class="truncate">Checking DNS...</span>
</x-status-badge>
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
@endif
</div>
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $preview->application)
@@ -160,12 +181,10 @@
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5" wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-reicon name="refresh" class="size-3.5" />
</button>
<button type="button" wire:click="startEdit({{ $index }})"
<button type="button"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']))"
class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ getFqdnWithoutPort($row['url']) }}">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -212,12 +231,12 @@
</header>
<div class="application-settings-section-body">
<form x-ref="editForm" wire:submit="updateDomain" class="flex flex-col gap-4">
<template x-if="editOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges" targets="updateDomain,confirmUseUnknownPort" />
</template>
@if ($editingIndex !== null && filled($domainRows[$editingIndex]['service'] ?? null))
<x-forms.input label="Service" :value="$domainRows[$editingIndex]['service']" readonly />
@endif
<div x-show="editingServiceLabel" x-cloak>
<div class="mb-1.5 flex h-4 items-center">
<label class="mb-0! leading-4">Service</label>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<x-forms.domain-input id="editingDomainParts" />
<div class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<x-forms.listbox id="preview-domain-indexing-{{ $preview->id }}" label="Search engine indexing"
@@ -233,6 +252,10 @@
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
<x-forms.button type="submit" isHighlighted>Save</x-forms.button>
</div>
</form>
</div>
</div>
@@ -1,4 +1,4 @@
<div wire:poll.10000ms="refreshStatus">
<div wire:poll.10000ms="refreshStatus" class="flex items-center gap-1">
<x-status-summary :status="$application->status" />
<x-application.restart-limit-warning :application="$application" />
</div>
@@ -1,12 +1,9 @@
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Backup schedule</h2>
<p>Choose what to back up, when it runs, and how long it may run.</p>
</div>
<x-application.settings-section title="Backup schedule"
description="Choose what to back up, when it runs, and how long it may run.">
<x-slot:actions>
<div class="flex items-center gap-2">
@if (! $backupEnabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
@@ -23,9 +20,9 @@
:disabled="! str($status)->startsWith('running')"
:tooltip="! str($status)->startsWith('running') ? 'The database must be running to start a backup.' : null">Back up now</x-forms.button>
</div>
</div>
</x-slot:actions>
<div class="application-settings-section-body space-y-5">
<div class="space-y-5">
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0
|| $backup->database_type === 'App\Models\StandaloneMysql'
|| $backup->database_type === 'App\Models\StandaloneMariadb')
@@ -96,5 +93,5 @@
helper="Notify through backup failure channels after this many days without an execution. Use 0 to disable." required />
</div>
</div>
</section>
</x-application.settings-section>
</form>
@@ -1,14 +1,9 @@
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Retention</h2>
<p>The first reached limit removes the oldest backup. Use 0 for unlimited retention.</p>
</div>
</div>
<div class="application-settings-section-body space-y-6">
<x-application.settings-section title="Retention"
description="The first reached limit removes the oldest backup. Use 0 for unlimited retention.">
<div class="space-y-6">
<div>
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -35,5 +30,5 @@
</div>
</div>
</div>
</section>
</x-application.settings-section>
</form>
@@ -1,4 +1,4 @@
<div wire:poll.10000ms="refreshStatus">
<div wire:poll.10000ms="refreshStatus" class="flex items-center gap-1">
<x-status-summary :status="$database->status" title="Database status" />
<x-application.restart-limit-warning :application="$database" />
</div>
@@ -76,7 +76,7 @@
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -150,7 +150,7 @@
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -0,0 +1,47 @@
<div class="grid gap-4 sm:grid-cols-2">
@if ($resourceType === 'application')
@if (str($serviceApplication->image)->contains('pocketbase'))
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
helper="PocketBase keeps compression disabled so server-sent events continue to work."
:disabled="true" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@else
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@endif
<x-forms.listbox id="isStripprefixEnabled" label="Path prefixes"
:options="[
['value' => true, 'label' => 'Strip prefixes'],
['value' => false, 'label' => 'Keep prefixes'],
]" />
<x-forms.listbox id="excludeFromStatus" label="Service status"
:options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
:options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
<x-forms.input type="number" min="0" id="maxRestartCount" label="Max restart count"
helper="Maximum number of Docker restarts before Coolify stops this container. Set to 0 to disable the limit. Docker counts expected and unexpected restarts."
canGate="update" :canResource="$serviceApplication" />
@else
<x-forms.listbox id="excludeFromStatus" label="Service status"
:options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
:options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
@endif
</div>
@@ -117,7 +117,7 @@
</div>
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -26,16 +26,29 @@
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
&& !$wire.showPortWarningModal && !$wire.showDomainConflictModal;
},
openEditDomain() {
openEditDomain(index, domain, parts, serviceApplicationId, serviceLabel, indexing, redirect) {
if (index !== undefined) {
$wire.set('editingIndex', index, false);
$wire.set('editingDomain', domain, false);
$wire.set('editingDomainParts', parts, false);
$wire.set('editingDomainPartsChanged', false, false);
$wire.set('editingServiceApplicationId', serviceApplicationId, false);
$wire.set('editingIndexing', indexing, false);
$wire.set('editingRedirect', redirect, false);
$wire.set('editingOriginalRedirect', redirect, false);
$wire.set('editingDomainWasRegenerated', false, false);
$wire.set('editingGeneratedHost', null, false);
}
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editingServiceLabel = $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name || '';
this.editingServiceLabel = serviceLabel ?? $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name ?? '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
},
closeEditDomain() {
closeEditDomain(discardDraft = true) {
this.modalOpen = false;
this.editingDomainBaseline = null;
this.editingServiceLabel = '';
if (discardDraft) this.$wire.cancelEdit();
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
@@ -45,7 +58,7 @@
},
}"
@open-edit-domain.window="openEditDomain()"
@edit-domain-saved.window="closeEditDomain()">
@edit-domain-saved.window="closeEditDomain(false)">
@if ($hasDnsChecksInProgress)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
@@ -56,9 +69,9 @@
@endcannot
{{-- Toolbar --}}
<div class="mt-2 flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 flex-1">
<h3>Domains</h3>
<h2 id="domains-section">Domains</h2>
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} across {{ $domainGroups->count() }} service{{ $domainGroups->count() === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
@@ -77,7 +90,7 @@
@endif
@can('update', $service)
@if ($configuredCount > 0)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled"
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled"
wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
@@ -239,10 +252,6 @@
<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">
<template x-if="modalOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges"
targets="updateDomain,confirmUpdateDomainDespiteDns" />
</template>
<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>
@@ -274,41 +283,32 @@
</div>
@endif
</form>
@php
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
@endphp
@if ($editingRow)
@can('update', $service)
@php
$editingAppId = (int) $editingRow['service_application_id'];
$editingDomainKey = hash('sha256', $editingRow['url'].'|'.$editingAppId);
$editingNoindex = $service->applications->firstWhere('id', $editingAppId)?->isDomainNoindexed($editingRow['url']);
@endphp
<div wire:key="editing-domain-settings-{{ $editingDomainKey }}"
@can('update', $service)
<div
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<p class="sm:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">Indexing and redirect changes save automatically.</p>
<x-forms.listbox id="service-domain-indexing-{{ $editingAppId }}-{{ $editingDomainKey }}"
label="Search engine indexing" :wire="false" preserveValue
:value="$editingNoindex ? 'noindex' : 'index'"
onChange="toggleNoindexDomain"
:onChangeArgs="[$editingAppId, $editingRow['url']]" portal
<x-forms.listbox id="editingIndexing" htmlId="service-domain-indexing"
label="Search engine indexing" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="service-domain-direction-{{ $editingAppId }}-{{ $editingDomainKey }}"
label="www redirect" :wire="false" :value="$serviceRedirects[$editingAppId] ?? 'both'" preserveValue
x-effect="value = $wire.serviceRedirects[{{ $editingAppId }}] ?? 'both'"
<x-forms.listbox id="editingRedirect" htmlId="service-domain-direction"
label="www redirect"
helper="Applies to all domains for this service application."
onChange="updateServiceRedirect" :onChangeArgs="[$editingAppId]" portal
portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@endcan
@endif
<div class="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
@unless ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="updateDomain" isHighlighted>Save</x-forms.button>
@endunless
</div>
@endcan
</div>
</div>
@@ -1,10 +1,12 @@
<div>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
@if ($resourceType === 'database')
@unless ($embedded)
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
@endunless
<section @class(['application-settings-workspace mt-4 w-full max-w-none lg:mt-0' => ! $embedded])>
<div @class(['grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8' => ! $embedded])>
@if (! $embedded && $resourceType === 'database')
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" />
@else
@elseif (! $embedded)
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@@ -19,95 +21,19 @@
<x-reicon name="settings" class="menu-item-icon" />
<span class="menu-item-label">General</span>
</a>
<a @class(['menu-item', 'menu-item-active' => request()->routeIs('project.service.index.advanced')])
{{ wireNavigate() }} href="{{ route('project.service.index.advanced', $parameters) }}">
<x-reicon name="grid" class="menu-item-icon" />
<span class="menu-item-label">Advanced</span>
</a>
</nav>
</aside>
@endif
<div class="min-w-0">
@if ($resourceType === 'application')
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify
</x-slot>
@if ($currentRoute === 'project.service.index.advanced')
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Advanced</h2>
<p>Control proxy, status, and logging behavior for this compose resource.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
@if (str($serviceApplication->image)->contains('pocketbase'))
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
helper="PocketBase keeps compression disabled so server-sent events continue to work."
:disabled="true" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@else
<x-forms.listbox id="isGzipEnabled" label="Gzip compression"
onChange="instantSaveApplicationSettings" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@endif
<x-forms.listbox id="isStripprefixEnabled" label="Path prefixes"
onChange="instantSaveApplicationSettings" :options="[
['value' => true, 'label' => 'Strip prefixes'],
['value' => false, 'label' => 'Keep prefixes'],
]" />
<x-forms.listbox id="excludeFromStatus" label="Service status"
onChange="instantSaveApplicationSettings" :options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
onChange="instantSaveApplicationAdvanced" :options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
</div>
</section>
@else
@unless ($embedded)
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceApplication, 'name')->limit(10) }} | Coolify
</x-slot>
@endunless
<form wire:submit="submitApplication" class="space-y-6">
<x-unsaved-bar action="submitApplication" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>{{ Str::headline($serviceApplication->human_name ?: $serviceApplication->name) }}</h2>
<p>Identity, image, and public access for this compose application.</p>
</div>
<div class="flex items-center gap-2">
@can('update', $serviceApplication)
<x-modal-confirmation wire:click="convertToDatabase" title="Convert to Database"
buttonTitle="Convert to Database" submitAction="convertToDatabase" :actions="['The selected resource will be converted to a service database.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
@can('delete', $serviceApplication)
<x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete" isErrorButton
submitAction="deleteApplication" :actions="['The selected service application container will be stopped and permanently deleted.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
</div>
</div>
<div class="application-settings-section-body space-y-4">
@if ($requiredPort && !$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
<x-callout type="info" title="Required Port: {{ $requiredPort }}" class="mb-2">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
<br><br>
<strong>Example:</strong> https://app.coolify.io:{{ $requiredPort }},https://www.app.coolify.io:{{ $requiredPort }}
</x-callout>
@endif
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input canGate="update" :canResource="$serviceApplication" label="Name" id="humanName"
placeholder="Human readable name"></x-forms.input>
@@ -116,7 +42,8 @@
</div>
<div class="grid gap-4 sm:grid-cols-2">
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
<div class="rounded-lg border border-neutral-200 p-4 dark:border-white/[0.08]">
<div data-domain-summary
class="rounded-lg border border-neutral-200 p-4 dark:border-white/[0.08]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
@php($domainCount = countDomains($fqdn))
@@ -140,8 +67,34 @@
helper="You can change the image you would like to deploy.<br><br><span class='dark:text-warning'>WARNING. You could corrupt your data. Only do it if you know what you are doing.</span>"
label="Image" id="image"></x-forms.input>
</div>
</div>
@include('livewire.project.service.advanced-settings')
<div data-service-resource-actions
class="flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div>
@can('delete', $serviceApplication)
<x-modal-confirmation title="Confirm Service Application Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteApplication"
:actions="['The selected service application container will be stopped and permanently deleted.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
@endcan
</div>
</section>
<div class="ml-auto flex items-center gap-2">
@can('update', $serviceApplication)
<x-modal-confirmation wire:click="convertToDatabase" title="Convert to Database"
buttonTitle="Convert to Database" submitAction="convertToDatabase"
:actions="['The selected resource will be converted to a service database.']"
confirmationText="{{ Str::headline($serviceApplication->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Application Name below"
shortConfirmationLabel="Service Application Name" />
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
@endcan
</div>
</div>
</form>
<x-domain-conflict-modal
@@ -212,64 +165,18 @@
</template>
</div>
@endif
@endif
@elseif ($resourceType === 'database')
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify
</x-slot>
@unless ($embedded)
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} | Coolify
</x-slot>
@endunless
@if ($currentRoute === 'project.service.database.import')
<livewire:project.database.import :resource="$serviceDatabase" :key="'import-' . $serviceDatabase->uuid" />
@elseif ($currentRoute === 'project.service.index.advanced')
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Advanced</h2>
<p>Control status aggregation and external log delivery.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="excludeFromStatus" label="Service status"
onChange="instantSaveExclude" :options="[
['value' => false, 'label' => 'Include in status'],
['value' => true, 'label' => 'Exclude from status'],
]" />
<x-forms.listbox id="isLogDrainEnabled" label="Log drain"
onChange="instantSaveLogDrain" :options="[
['value' => true, 'label' => 'Send logs to drain'],
['value' => false, 'label' => 'Do not drain logs'],
]" />
</div>
</section>
@else
<form wire:submit="submitDatabase" class="space-y-6">
<x-unsaved-bar action="submitDatabase" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>{{ Str::headline($serviceDatabase->human_name ?: $serviceDatabase->name) }}</h2>
<p>Identity, image, and public access for this compose database.</p>
</div>
<div class="flex items-center gap-2">
@can('update', $serviceDatabase)
<x-modal-confirmation wire:click="convertToApplication" title="Convert to Application"
buttonTitle="Convert to Application" submitAction="convertToApplication" :actions="['The selected resource will be converted to an application.']"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
@can('delete', $serviceDatabase)
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteDatabase" :actions="[
'The selected service database container will be stopped and permanently deleted.',
]"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
</div>
</div>
<div class="application-settings-section-body space-y-5">
<div class="space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input canGate="update" :canResource="$serviceDatabase" label="Name" id="humanName"
placeholder="Name"></x-forms.input>
@@ -330,8 +237,35 @@
@endif
</div>
</div>
</div>
@include('livewire.project.service.advanced-settings')
<div data-service-resource-actions
class="flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div>
@can('delete', $serviceDatabase)
<x-modal-confirmation title="Confirm Service Database Deletion?" buttonTitle="Delete"
isErrorButton submitAction="deleteDatabase" :actions="[
'The selected service database container will be stopped and permanently deleted.',
]"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
@endcan
</div>
</section>
<div class="ml-auto flex items-center gap-2">
@can('update', $serviceDatabase)
<x-modal-confirmation wire:click="convertToApplication" title="Convert to Application"
buttonTitle="Convert to Application" submitAction="convertToApplication"
:actions="['The selected resource will be converted to an application.']"
confirmationText="{{ Str::headline($serviceDatabase->name) }}"
confirmationLabel="Please confirm the execution of the actions by entering the Service Database Name below"
shortConfirmationLabel="Service Database Name" />
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
@endcan
</div>
</div>
</form>
@endif
@endif
@@ -57,6 +57,10 @@
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
: null;
$domainKey = hash('sha256', $row['url'].'|'.($row['service_application_id'] ?? ''));
$editingParts = \App\Support\DomainUrlParts::split($row['url']);
if ($row['has_port_override'] ?? false) {
$editingParts['port'] = (string) $row['internal_port'];
}
@endphp
<div wire:key="svc-domain-{{ $row['service_application_id'] ?? 'x' }}-{{ md5(($isSuggested ? 's:' : '') . $row['url']) }}"
@@ -150,6 +154,11 @@
@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]" />
@elseif ($row['dns_status'] === 'checking')
<x-status-badge dynamic :title="$row['dns_message']">
<x-loading compact aria-label="Checking DNS" />
<span class="truncate">Checking DNS...</span>
</x-status-badge>
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@@ -162,11 +171,7 @@
wire:loading.attr="disabled"
wire:target="checkDomainDns({{ $index }}),checkAllDns"
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
<x-reicon name="refresh" class="size-3.5"
wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-reicon name="refresh" class="size-3.5" />
</button>
@if ($isSuggested)
@if ($row['needs_force_add'] ?? false)
@@ -184,7 +189,7 @@
@endif
@else
<button type="button" class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ $publicUrl }}"
wire:click="startEdit({{ $index }})" wire:loading.attr="disabled" wire:target="startEdit">
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js((int) $row['service_application_id']), @js($serviceLabel), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
@@ -1,13 +1,4 @@
<div x-data="{
settingsUrl: @js(route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid])),
openSettings(event) {
if (event.target.closest('a, button')) {
return;
}
Livewire.navigate(this.settingsUrl);
}
}">
@php
@php
[$statusType, $statusLabel] = match (true) {
str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)],
str($resource->status)->contains(['starting', 'restarting', 'degraded']) => ['warning', formatContainerStatus($resource->status)],
@@ -16,7 +7,20 @@
$resourceName = $resource->human_name
? Str::headline($resource->human_name)
: Str::headline($resource->name);
@endphp
@endphp
<x-modal-input title="{{ $resourceName }}"
subtitle="{{ $isApplication ? 'Identity, image, and public access for this compose application.' : 'Identity, image, and public access for this compose database.' }}"
:contentClicks="false" :wireIgnore="false" isLarge>
<x-slot:content>
<div x-data="{
openSettings(event) {
if (event.target.closest('a, button')) {
return;
}
modalOpen = true;
}
}">
<div x-cloak x-show="viewMode === 'grid'"
class="group flex min-w-0 flex-col overflow-hidden rounded-[10px] border border-neutral-200 bg-white transition-[border-color,background-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-surface dark:hover:border-white/[0.12] dark:hover:bg-white/[0.035]">
@@ -66,10 +70,10 @@
</a>
@endcan
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<button type="button" class="icon-button" title="Resource settings" aria-label="Resource settings"
@click="modalOpen = true">
<x-reicon name="settings" class="size-4" />
</a>
</button>
@if (str($resource->status)->contains('running'))
@can('update', $service)
<x-modal-confirmation
@@ -119,10 +123,20 @@
</a>
@endcan
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<button type="button" class="icon-button" title="Resource settings" aria-label="Resource settings"
@click="modalOpen = true">
<x-reicon name="settings" class="size-4" />
</a>
</button>
</div>
</div>
</div>
</div>
</x-slot:content>
@if ($isApplication)
<livewire:project.service.index :serviceApplication="$resource" :embedded="true"
wire:key="service-application-settings-{{ $resource->id }}" lazy />
@else
<livewire:project.service.index :serviceApplication="$resource" :embedded="true"
wire:key="service-database-settings-{{ $resource->id }}" lazy />
@endif
</x-modal-input>
@@ -11,10 +11,11 @@
@can('update', $service)
<x-modal-input buttonTitle="Edit Compose file" title="Docker Compose" :closeOutside="false"
:isLarge="true">
<x-slot:headerActions>
<x-slot:footer>
<div x-data="{ preview: false, validating: false, saving: false }"
@compose-validate-finished.window="validating = false"
@compose-save-finished.window="saving = false" class="flex w-full items-center gap-2 overflow-x-auto sm:w-auto">
@compose-save-finished.window="saving = false"
class="flex flex-wrap items-center justify-end gap-2">
<x-forms.button
@click="preview = !preview; $dispatch('compose-preview-toggle')">
<x-reicon name="eye" class="size-3.5" />
@@ -33,7 +34,7 @@
Save changes
</x-forms.button>
</div>
</x-slot:headerActions>
</x-slot:footer>
<livewire:project.service.edit-compose serviceId="{{ $service->id }}" />
</x-modal-input>
@endcan
@@ -1,4 +1,4 @@
<div wire:poll.10000ms="refreshStatus">
<div wire:poll.10000ms="refreshStatus" class="flex items-center gap-1">
@php($displayStatus = $selectedResource?->status ?? $service->status)
<x-status-summary :status="$displayStatus" :title="$selectedResource ? 'Resource status' : 'Service status'"
:container-name="$selectedResource ? 'Container' : 'Containers'" />
@@ -296,7 +296,11 @@
wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')"
wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">Settings</x-forms.button>
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
</span>
</div>
@endforeach
@@ -346,7 +350,11 @@
wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')"
wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">Settings</x-forms.button>
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
</span>
</div>
@endforeach
@@ -501,9 +501,11 @@
<div class="logs-viewer-meta">
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
<span class="logs-viewer-lines-label">Lines</span>
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
<input type="number" wire:model="numberOfLines" placeholder="100" min="-1" max="50000"
title="Number of lines (max 50,000; use -1 for all)" {{ $streamLogs ? 'readonly' : '' }}
class="input logs-viewer-lines-input" />
<button type="button" wire:click="showAllLogs" title="Show all logs"
class="runtime-log-icon-button" {{ $streamLogs ? 'disabled' : '' }}>All</button>
</form>
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-gray-500 whitespace-nowrap dark:text-gray-400"></span>
@@ -96,7 +96,7 @@
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -106,7 +106,7 @@
<span x-text="filteredServers.length === 1 ? 'server' : 'servers'"></span>
</span>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -6,7 +6,7 @@
</div>
<div class="flex items-center gap-3">
<span class="text-[11px] text-neutral-500 dark:text-fg-faint"><span x-text="filteredItems.length"></span> <span x-text="filteredItems.length === 1 ? '{{ $singular }}' : '{{ $plural }}'"></span></span>
<div class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<div class="view-toggle">
<button type="button" x-on:click="setViewMode('table')" class="flex size-7.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
<button type="button" x-on:click="setViewMode('grid')" class="flex size-7.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
</div>
@@ -1,6 +1,6 @@
<div>
<x-slot:title>
{{ $gitlab_app->name ?: 'GitLab App' }} | Sources | Coolify
{{ $name ?: 'GitLab App' }} | Sources | Coolify
</x-slot>
@if ($isConnected)
+1 -1
View File
@@ -69,7 +69,7 @@
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
+4 -3
View File
@@ -4,6 +4,7 @@ use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -463,7 +464,7 @@ describe('POST /api/v1/s3-storages/{uuid}/validate', function () {
test('validates a working s3 storage connection', function () {
$storage = createS3StorageForTeam($this->team);
$disk = Mockery::mock();
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')->once()->andReturn($disk);
@@ -484,7 +485,7 @@ describe('POST /api/v1/s3-storages/{uuid}/validate', function () {
test('detects an invalid s3 storage connection', function () {
$storage = createS3StorageForTeam($this->team, ['is_usable' => true]);
$disk = Mockery::mock();
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->expects('files')
->once()
->andThrow(new RuntimeException('Access Denied'));
@@ -519,7 +520,7 @@ describe('POST /api/v1/s3-storages/{uuid}/validate', function () {
test('writes an audit log entry when validating storage', function () {
$storage = createS3StorageForTeam($this->team, ['name' => 'Audit Storage']);
$disk = Mockery::mock();
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->expects('files')->once()->andReturn([]);
Storage::expects('build')->once()->andReturn($disk);
+371 -25
View File
@@ -19,6 +19,7 @@ use App\Models\Team;
use App\Models\User;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
@@ -108,7 +109,7 @@ it('uses safe domain validation rules on the domains form', function () {
});
it('does not add a single-label hostname as an application domain', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'aaa')
->call('addDomain')
->assertDispatched('error');
@@ -632,7 +633,7 @@ it('lists existing domains as individual rows', function () {
it('shows the HTTP redirect control for HTTPS domains and persists changes', function () {
$this->application->update(['fqdn' => 'https://app.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('isForceHttpsEnabled', true)
->assertSee('Redirect HTTP to HTTPS')
->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.')
@@ -669,8 +670,9 @@ it('shows the compose service redirect control in domain settings', function ()
->assertSee('www redirect')
->html();
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0)
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
->and($html)->toContain('editingRedirect')
->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0);
});
@@ -935,7 +937,7 @@ it('updates a domain in place via modal', function () {
'fqdn' => 'https://old.example.com,https://keep.example.com',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->assertSet('showEditDomainModal', true)
->assertSet('editingDomain', 'https://old.example.com')
@@ -955,7 +957,7 @@ it('updates a domain in place via modal', function () {
expect($this->application->fqdn)->toBe('https://new.example.com,https://keep.example.com');
});
it('blocks editing a domain with bad dns until the user continues', function () {
it('saves an edited domain and records its dns result without a confirmation gate', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = true;
$settings->save();
@@ -969,21 +971,14 @@ it('blocks editing a domain with bad dns until the user continues', function ()
->set('editingDomainParts.scheme', 'https')
->set('editingDomainParts.host', 'this-domain-should-not-resolve-for-coolify-tests.invalid')
->call('updateDomain')
->assertSet('editDomainDnsFailed', true)
->assertSet('showEditDomainModal', true)
->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');
$component->call('confirmUpdateDomainDespiteDns')
->assertSet('editDomainDnsFailed', false)
->assertSet('showEditDomainModal', false)
->assertDispatched('success');
$this->application->refresh();
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid');
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid')
->and($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null)
->toBe('failed');
});
it('removes a domain', function () {
@@ -1948,6 +1943,123 @@ it('saves after confirming a domain conflict on add', function () {
]);
});
it('warns when adding a domain used by a docker compose application', function () {
Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Compose Conflict App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => null,
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://compose-taken.example.com', 'redirect' => 'both'],
]),
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomain', 'https://compose-taken.example.com')
->call('addDomain')
->assertSet('showDomainConflictModal', true)
->assertSet('domainConflicts.0.service_name', 'web')
->assertSet('pendingAction', 'add');
expect($this->application->fresh()->fqdn)->toBeNull();
});
it('warns when a compose domain uses a different scheme', function () {
Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'HTTP Compose Conflict App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => null,
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'http://scheme-conflict.example.com', 'redirect' => 'both'],
]),
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomain', 'https://scheme-conflict.example.com')
->call('addDomain')
->assertSet('showDomainConflictModal', true)
->assertSet('domainConflicts.0.service_name', 'web');
expect($this->application->fresh()->fqdn)->toBeNull();
});
it('shows the compose service name in the domain conflict modal', function () {
$html = Blade::render(
'<x-domain-conflict-modal :conflicts="$conflicts" :show-modal="true" />',
['conflicts' => [[
'domain' => 'https://compose-taken.example.com',
'resource_name' => 'Compose Conflict App',
'resource_link' => '#',
'resource_type' => 'application',
'service_name' => 'web',
]]],
);
expect($html)->toContain('(application: web)');
});
it('checks each domain configured for one docker compose service', function () {
Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Conflicting App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://second-compose.example.com',
'build_pack' => 'nixpacks',
]);
$this->application->update([
'build_pack' => 'dockercompose',
'fqdn' => null,
'docker_compose_domains' => json_encode([
'web' => [
'domain' => 'https://first-compose.example.com,https://second-compose.example.com',
'redirect' => 'both',
],
]),
]);
$result = checkDomainUsage(resource: $this->application->fresh());
expect($result['hasConflicts'])->toBeTrue()
->and($result['conflicts'])->toHaveCount(1)
->and($result['conflicts'][0]['domain'])->toBe('https://second-compose.example.com');
});
it('uses docker compose domains in API conflict checks', function () {
$composeApplication = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Compose API Conflict App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => null,
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'api' => ['domain' => 'https://compose-api.example.com', 'redirect' => 'both'],
]),
]);
$result = checkIfDomainIsAlreadyUsedViaAPI(
['https://compose-api.example.com'],
(string) $this->team->id,
$this->application->uuid,
);
expect($result['hasConflicts'])->toBeTrue()
->and($result['conflicts'])->toHaveCount(1)
->and($result['conflicts'][0]['resource_uuid'])->toBe($composeApplication->uuid)
->and($result['conflicts'][0]['service_name'])->toBe('api');
});
it('saves after confirming a domain conflict on edit', function () {
Application::factory()->create([
'uuid' => (string) Str::uuid(),
@@ -2049,11 +2161,22 @@ it('uses the compact service domains layout for compose applications', function
->not->toContain('<span>Last checked</span>')
->not->toContain('id="edit-domain-direction"')
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"')
->toContain('id="application-domain-direction-{{ $editingKey }}"')
->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
->toContain('htmlId="application-domain-direction"')
->toContain('id="editingRedirect"')
->not->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
->not->toContain('title="No domains for this service"');
});
it('uses a compact two row domain card at medium widths', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('@container service-domains (max-width: 980px)')
->toContain('grid-template-columns: minmax(0, 1fr) auto auto;')
->toContain('grid-template-areas: "domain dns actions" "summary summary summary";')
->toContain('grid-area: summary;');
});
it('uses concise search indexing headers in application and service domain tables', function () {
$applicationView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$serviceView = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
@@ -2077,6 +2200,232 @@ it('shows a form save button at the bottom of application domain settings', func
->not->toContain('<x-unsaved-bar action="updateDomain"');
});
it('opens application domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
});
it('uses the dns badge as progress for single and all application checks', function (string $action, array $parameters) {
Queue::fake();
$this->application->update(['fqdn' => 'https://badge.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call($action, ...$parameters)
->assertSet('domainRows.0.dns_status', 'checking')
->assertSee('Checking DNS...')
->assertSeeHtml('loading-indicator');
Queue::assertPushed(CheckDomainDnsJob::class, 1);
})->with([
'single domain' => ['checkDomainDns', [0]],
'all domains' => ['checkAllDns', []],
]);
it('keeps domain settings as a draft until the modal is saved', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingIndexing', 'noindex')
->set('editingRedirect', 'www');
expect($this->application->fresh()->redirect)->toBe('both')
->and($this->application->noindexDomains()->all())->toBe([]);
});
it('saves all domain modal settings together', function () {
Queue::fake();
$this->application->update([
'fqdn' => 'https://app.example.com',
'redirect' => 'both',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingIndexing', 'noindex')
->set('editingRedirect', 'www')
->call('updateDomain')
->assertHasNoErrors()
->assertDispatched('success');
$application = $this->application->fresh();
expect($application->redirect)->toBe('www')
->and($application->noindexDomains()->all())->toBe(['https://app.example.com'])
->and(explode(',', $application->fqdn))->toContain('https://www.app.example.com');
Queue::assertPushed(CheckDomainDnsJob::class, 1);
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://www.app.example.com');
});
it('regenerates an application domain as a draft while preserving its url settings', function () {
$this->server->settings()->update(['wildcard_domain' => 'https://wildcard.example.net']);
$this->application->update(['fqdn' => 'http://old.example.com:8080/api']);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->call('regenerateEditingDomain')
->assertSet('editingDomainParts.scheme', 'http')
->assertSet('editingDomainParts.port', '8080')
->assertSet('editingDomainParts.path', '/api');
expect($component->get('editingDomainParts')['host'])
->toEndWith('.sslip.io')
->not->toBe('old.example.com')
->and($this->application->fresh()->fqdn)->toBe('http://old.example.com/api')
->and($this->application->fresh()->domain_port_overrides)->toMatchArray([
'http://old.example.com/api' => 8080,
]);
});
it('starts a dns check after a manually edited application domain is saved', function () {
Queue::fake();
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = true;
$settings->save();
$this->application->update(['fqdn' => 'https://old.example.com:81']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '81')
->set('editingDomainParts.host', 'manual.example.com')
->call('updateDomain')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
fn (array $row): bool => $row['url'] === 'https://manual.example.com' && $row['dns_status'] === 'checking'
));
expect($this->application->fresh()->domain_dns_statuses['https://manual.example.com']['status'] ?? null)->toBe('checking');
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual.example.com'
&& $job->statusKey === 'https://manual.example.com');
});
it('does not start a dns check when only application domain settings change', function () {
Queue::fake();
$this->application->update(['fqdn' => 'https://unchanged.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingIndexing', 'noindex')
->call('updateDomain')
->assertHasNoErrors();
Queue::assertNotPushed(CheckDomainDnsJob::class);
});
it('starts a dns check when the application domain scheme changes', function () {
Queue::fake();
$this->application->update(['fqdn' => 'http://scheme.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingDomainParts.scheme', 'https')
->call('updateDomain')
->assertHasNoErrors();
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://scheme.example.com');
});
it('does not start a dns check when only the internal port changes', function () {
Queue::fake();
$this->application->update([
'fqdn' => 'https://port.example.com:81',
'ports_exposes' => '81,82',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingDomainParts.port', '82')
->call('updateDomain')
->assertHasNoErrors();
Queue::assertNotPushed(CheckDomainDnsJob::class);
});
it('regenerates configured www pairs together and preserves each url settings', function () {
$this->server->settings()->update(['wildcard_domain' => 'https://wildcard.example.net']);
$this->application->update([
'fqdn' => 'http://app.example.com:8080/api,https://www.app.example.com:9090/admin',
'redirect' => 'www',
'noindex_domains' => ['https://www.app.example.com/admin'],
]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->call('regenerateEditingDomain');
$generatedHost = $component->get('editingDomainParts')['host'];
$component->call('updateDomain')->assertHasNoErrors();
$application = $this->application->fresh();
expect(explode(',', $application->fqdn))->toBe([
"http://{$generatedHost}/api",
"https://www.{$generatedHost}/admin",
])->and($application->noindexDomains()->all())->toBe([
"https://www.{$generatedHost}/admin",
])->and($application->domain_port_overrides)->toMatchArray([
"http://{$generatedHost}/api" => 8080,
"https://www.{$generatedHost}/admin" => 9090,
]);
});
it('does not create a missing redirect counterpart while regenerating one domain', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
'redirect' => 'www',
]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->call('regenerateEditingDomain');
$generatedHost = $component->get('editingDomainParts')['host'];
$component->call('updateDomain')->assertHasNoErrors();
expect($this->application->fresh()->fqdn)->toBe("https://{$generatedHost}");
});
it('saves regenerated compose domain drafts for only the selected service', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://web.example.com/api', 'redirect' => 'both'],
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
]),
]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
$webIndex = collect($component->get('domainRows'))->search(
fn (array $row): bool => ($row['service'] ?? null) === 'web' && ! ($row['is_suggested'] ?? false)
);
$component
->call('startEdit', $webIndex)
->set('editingIndexing', 'noindex')
->set('editingRedirect', 'www')
->call('regenerateEditingDomain');
$generatedHost = $component->get('editingDomainParts')['host'];
$component->call('updateDomain')->assertHasNoErrors();
$application = $this->application->fresh();
$domains = json_decode($application->docker_compose_domains, true);
expect($domains['web']['domain'])->toBe("https://{$generatedHost}/api,https://www.{$generatedHost}/api")
->and($domains['web']['redirect'])->toBe('www')
->and($domains['api'])->toMatchArray(['domain' => 'https://api.example.com', 'redirect' => 'both'])
->and($application->noindexDomains()->all())->toBe(["https://{$generatedHost}/api"]);
});
it('does not render a last checked column in the domains table', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
@@ -2301,18 +2650,15 @@ it('updates search engine indexing from the domains view', function () {
->assertSee('Indexable')
->assertSee('Search engine indexing')
->assertSee('www redirect')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateRedirect', false)
->assertSee('wire:ignore', false)
->assertDontSee('x-model="localIndexing"', false)
->assertDontSee('x-model="localDirection"', false)
->assertDontSee('@js(', false)
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
->assertSee('editingIndexing', false)
->assertDontSee('toggleNoindexDomain', false)
->set('editingIndexing', 'noindex')
->call('updateDomain')
->assertDispatched('configurationChanged')
->assertDispatched('success');
expect($this->application->refresh()->noindexDomains()->all())
->toBe(['https://staging.example.com']);
->toBe(['https://app.example.com']);
});
it('updates search engine indexing for a git docker compose domain', function () {
@@ -10,13 +10,18 @@ it('shows internal Docker access details in application general settings', funct
expect($generalSettings)
->toContain('id="access-section" title="Access"')
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
->toContain("Str::plural('domain', \$domainCount)")
->toContain('Domains, DNS checks, and redirect settings')
->toContain('class="flex items-center gap-3 rounded-lg')
->toContain('class="icon-button ml-auto shrink-0"')
->toContain('aria-label="Manage domains"')
->toContain('<x-reicon name="settings" class="size-4" />')
->not->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
->toContain('$primaryDomain = null;')
->toContain('$additionalDomainCount = max(0, $domainCount - 1);')
->toContain("aria-label=\"{{ \$domainCount > 0 ? 'Manage application domains' : 'Add an application domain' }}\"")
->toContain('{{ $primaryDomain }}')
->toContain("+{{ \$additionalDomainCount }} more {{ Str::plural('domain', \$additionalDomainCount) }}")
->toContain('No public domain configured')
->toContain('Make this application available from a URL')
->toContain("{{ \$domainCount > 0 ? 'Manage domains' : 'Add domain' }}")
->toContain('class="button relative z-10 ml-auto shrink-0"')
->toContain('aria-label="{{ $domainCount > 0 ? \'Manage application domains\' : \'Add an application domain\' }}"')
->toContain('<x-reicon name="arrow-right" class="size-4" />')
->not->toContain('<x-reicon name="settings" class="size-4" />')
->toContain('<livewire:project.application.internal-access')
->not->toContain('wire:init="loadCurrentInternalHostname"')
->and($internalAccessSettings)
@@ -288,6 +288,18 @@ test('admin can update email notification settings', function () {
expect($this->admin->can('update', $settings))->toBeTrue();
});
test('admin can disable restart limit email notifications', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->assertSet('restartLimitReachedEmailNotifications', true)
->call('toggleEvent', 'restartLimitReachedEmailNotifications')
->assertSet('restartLimitReachedEmailNotifications', false);
expect($this->team->emailNotificationSettings->fresh()->restart_limit_reached_email_notifications)->toBeFalse();
});
test('admin can save team smtp settings without a resend api key when resend is disabled', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
@@ -8,6 +8,8 @@ use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
@@ -343,6 +345,61 @@ it('disables S3 backup when saved without a selected S3 storage', function () {
expect($backup->s3_storage_id)->toBeNull();
});
it('deletes a service database backup schedule without rendering the deleted relationship', function () {
InstanceSettings::get()->update(['disable_two_step_confirmation' => true]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'server_id' => $server->id,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$database = ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'enabled' => true,
'save_s3' => false,
'database_backup_retention_amount_locally' => 0,
'database_backup_retention_days_locally' => 0,
'database_backup_retention_max_storage_locally' => 0,
'database_backup_retention_amount_s3' => 0,
'database_backup_retention_days_s3' => 0,
'database_backup_retention_max_storage_s3' => 0,
'dump_all' => false,
'timeout' => 3600,
'missing_backup_notification_days' => 0,
'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
'team_id' => $this->team->id,
]);
$parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
];
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup,
'availableS3Storages' => collect(),
'section' => 'danger',
]);
$component
->call('delete', '')
->assertRedirectToRoute('project.service.database.backups', $parameters);
expect(ScheduledDatabaseBackup::find($backup->id))->toBeNull();
});
it('cascades to disabling local backup deletion when S3 is force-disabled', function () {
$backup = createBackupForEditValidationTest($this->team, [
'disable_local_backup' => true,
@@ -0,0 +1,35 @@
<?php
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('checks installed Traefik versions after refreshing available versions', function () {
Bus::fake();
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.10']],
'traefik' => ['v3.7' => '3.7.13'],
]),
]);
File::shouldReceive('exists')->andReturn(false);
File::shouldReceive('put')->once();
InstanceSettings::forceCreate(['id' => 0]);
config([
'app.env' => 'production',
'constants.coolify.self_hosted' => true,
'constants.coolify.version' => '4.0.10',
]);
(new CheckForUpdatesJob)->handle();
Bus::assertDispatched(CheckTraefikVersionJob::class);
});
+6 -5
View File
@@ -9,7 +9,8 @@ it('uses the large modal treatment for the compose editor', function () {
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:headerActions>')
->toContain('<x-slot:footer>')
->not->toContain('<x-slot:headerActions>')
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
@@ -57,12 +58,12 @@ it('keeps the compose modal usable on mobile screens', function () {
->toContain('justify-center p-2')
->toContain('sm:p-4')
->toContain('flex-wrap! sm:flex-nowrap!')
->toContain('order-3 w-full sm:order-none sm:w-auto')
->toContain('order-2 sm:order-none')
->toContain("'mt-2 sm:mt-0' => isset(\$headerActions)");
->toContain('@isset($footer)')
->toContain('justify-end gap-2 border-t')
->toContain('order-2 sm:order-none');
expect($stackForm)
->toContain('w-full items-center gap-2 overflow-x-auto sm:w-auto');
->toContain('flex-wrap items-center justify-end gap-2');
expect($editor)
->toContain('flex-col items-stretch')
@@ -17,3 +17,11 @@ test('dashboard navbar keeps the original side-by-side tab and actions layout',
->toContain('flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between')
->toContain('flex min-w-0 w-full items-center gap-0.5 overflow-x-auto');
});
test('dashboard view all links use the shared button style', function () {
$contents = file_get_contents(resource_path('views/components/section-heading.blade.php'));
expect($contents)
->toContain('class="button group"')
->not->toContain('class="group inline-flex h-7');
});
@@ -57,3 +57,15 @@ it('spaces instance backup settings sections', function () {
expect($edit)
->toStartWith('<div class="flex flex-col gap-6">');
});
it('shows database backup section descriptions from the shared title helper', function () {
$general = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
$retention = file_get_contents(resource_path('views/livewire/project/database/backup-edit/retention.blade.php'));
expect($general)
->toContain('<x-application.settings-section title="Backup schedule"')
->not->toContain('<h2>Backup schedule</h2>')
->and($retention)
->toContain('<x-application.settings-section title="Retention"')
->not->toContain('<h2>Retention</h2>');
});
@@ -74,6 +74,29 @@ describe('GetLogs locked properties', function () {
});
describe('GetLogs Livewire action validation', function () {
test('getLogs requests all logs when the line count is minus one', function () {
$this->server->settings->fill([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
])->save();
$server = Server::with('settings')->findOrFail($this->server->id);
Process::fake(['*' => Process::result(output: 'all logs')]);
Livewire::test(GetLogs::class, [
'server' => $server,
'resource' => $this->application,
'container' => 'test-container',
])
->assertSee('All')
->assertSeeHtml('title="Show all logs"')
->call('showAllLogs')
->assertSet('numberOfLines', -1);
Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all'));
});
test('getLogs marks ANSI-colored output truncated based on raw bytes', function () {
$this->server->settings->fill([
'is_reachable' => true,
@@ -57,6 +57,23 @@ describe('GitLab source setup view', function () {
->assertSet('apiUrl', 'https://gitlab.example.com/api/v4');
});
test('redirects without rendering an error after the gitlab app is deleted', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->call('delete')
->assertRedirect(route('source.all'));
$this->assertModelMissing($this->gitlabApp);
});
test('uses the persisted name for the title during a post-delete update', function () {
$view = file_get_contents(resource_path('views/livewire/source/gitlab/change.blade.php'));
expect($view)
->toContain("{{ \$name ?: 'GitLab App' }} | Sources | Coolify")
->not->toContain("{{ \$gitlab_app->name ?: 'GitLab App' }} | Sources | Coolify");
});
test('saves and reloads the application secret after refresh', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
@@ -1,5 +1,6 @@
<?php
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Project\Application\PreviewDomains;
use App\Models\Application;
use App\Models\ApplicationPreview;
@@ -12,11 +13,22 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('opens preview domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
});
beforeEach(function () {
$this->withoutVite();
config(['app.maintenance.driver' => 'file']);
@@ -90,6 +102,80 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
], $attributes));
}
it('regenerates an existing preview domain only when the modal is saved', function () {
$preview = createPreviewForPortTests($this->application, 100, [
'fqdn' => 'http://preview.example.com:8080/api',
]);
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->call('regenerateEditingDomain')
->assertSet('editingDomainParts.scheme', 'http')
->assertSet('editingDomainParts.port', '8080')
->assertSet('editingDomainParts.path', '/api');
$generatedHost = $component->get('editingDomainParts')['host'];
expect($preview->fresh()->fqdn)->toBe('http://preview.example.com/api');
$component->call('updateDomain')->assertHasNoErrors();
expect($preview->fresh()->fqdn)->toBe("http://{$generatedHost}/api")
->and($preview->fresh()->domain_port_overrides)->toHaveKey("http://{$generatedHost}/api", 8080);
});
it('runs a dns check after a manually edited preview domain is saved', function () {
Queue::fake();
$preview = createPreviewForPortTests($this->application, 99, [
'fqdn' => 'https://old-preview.example.com:81',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '81')
->set('editingDomainParts.host', 'manual-preview.example.com')
->call('updateDomain')
->assertSet('domainRows.0.dns_status', 'checking');
expect(collect($preview->fresh()->domain_dns_statuses)->contains(
fn (array $status): bool => ($status['status'] ?? null) === 'checking'
))->toBeTrue();
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual-preview.example.com'
&& $job->statusKey === hash('sha256', 'https://manual-preview.example.com|'));
});
it('uses the dns badge as progress for single and all preview checks', function (string $action, array $parameters) {
Queue::fake();
$preview = createPreviewForPortTests($this->application, 98, [
'fqdn' => 'https://badge-preview.example.com',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call($action, ...$parameters)
->assertSet('domainRows.0.dns_status', 'checking')
->assertSee('Checking DNS...')
->assertSeeHtml('loading-indicator');
Queue::assertPushed(CheckDomainDnsJob::class, 1);
})->with([
'single domain' => ['checkDomainDns', [0]],
'all domains' => ['checkAllDns', []],
]);
it('does not start a dns check when a preview domain address is unchanged', function () {
Queue::fake();
$preview = createPreviewForPortTests($this->application, 100, [
'fqdn' => 'https://unchanged-preview.example.com',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->call('updateDomain')
->assertHasNoErrors();
Queue::assertNotPushed(CheckDomainDnsJob::class);
});
it('saves preview domain port overrides separately from the public FQDN', function () {
$preview = createPreviewForPortTests($this->application, 101);
+2 -4
View File
@@ -2,17 +2,15 @@
use App\Livewire\Storage\Form;
use App\Models\S3Storage;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class);
it('tests the S3 connection with the values currently entered in the form', function () {
if (! defined('CURLOPT_RESOLVE')) {
define('CURLOPT_RESOLVE', 10203);
}
$disk = Mockery::mock();
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->expects('files')->once()->andReturn([]);
$testedConfig = null;
@@ -0,0 +1,86 @@
<?php
use App\Livewire\Project\Service\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->serviceApplication = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'worker',
'service_id' => $service->id,
'image' => 'example/worker:latest',
'max_restart_count' => 10,
]);
});
it('shows and saves the restart limit for a compose application', function () {
$this->serviceApplication->update(['restart_limit_reached' => true]);
Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication->fresh()])
->assertSet('maxRestartCount', 10)
->set('currentRoute', 'project.service.index')
->assertSee('Max restart count')
->assertSee('Set to 0 to disable the limit.')
->set('maxRestartCount', 0)
->call('saveMaxRestartCount')
->assertHasNoErrors()
->assertDispatched('success');
expect($this->serviceApplication->fresh())
->max_restart_count->toBe(0)
->restart_limit_reached->toBeFalse();
});
it('validates the restart limit', function (mixed $value) {
Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication])
->set('maxRestartCount', $value)
->call('saveMaxRestartCount')
->assertHasErrors('maxRestartCount');
})->with([
'negative' => -1,
'decimal' => 1.5,
'text' => 'unlimited',
]);
it('does not let a team member change the restart limit', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
Livewire::test(Index::class, ['serviceApplication' => $this->serviceApplication])
->set('maxRestartCount', 0)
->call('saveMaxRestartCount')
->assertForbidden();
expect($this->serviceApplication->fresh()->max_restart_count)->toBe(10);
});
@@ -259,6 +259,37 @@ describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
expect($ctx->serviceApplication->fresh()->is_force_https_enabled)->toBeFalse();
});
test('updates the maximum restart count', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$ctx->serviceApplication->update(['restart_limit_reached' => true]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'max_restart_count' => 0,
])->assertSuccessful()
->assertJsonPath('max_restart_count', 0)
->assertJsonPath('restart_limit_reached', false);
expect($ctx->serviceApplication->fresh())
->max_restart_count->toBe(0)
->restart_limit_reached->toBeFalse();
});
test('rejects an invalid maximum restart count', function (mixed $value) {
$ctx = createServiceWithApplicationForApiTest($this);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'max_restart_count' => $value,
])->assertJsonValidationErrors('max_restart_count');
})->with([
'negative' => -1,
'decimal' => 1.5,
'text' => 'unlimited',
]);
test('returns 422 for invalid url scheme', function () {
$ctx = createServiceWithApplicationForApiTest($this);
@@ -12,6 +12,8 @@ it('provides grid and table views for compose resources without sorting controls
->toContain('aria-label="Grid view"')
->toContain('mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between')
->toContain('flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start')
->toContain('class="view-toggle"')
->not->toContain('flex h-9 items-center rounded-lg border border-neutral-200 bg-white')
->toContain("localStorage.setItem('service-compose-resources-view', mode)")
->not->toContain('>Sort</button>')
->and($resourceCard)
@@ -254,12 +254,15 @@ it('adds a back up now action to every service backup schedule row', function ()
expect($index)
->toContain('<span class="text-right">Actions</span>')
->toContain('<div class="min-w-[59rem]">')
->toContain('<div class="min-w-[64rem]">')
->toContain("wire:click.stop=\"backupNow('database',")
->toContain("wire:click.stop=\"backupNow('storage',")
->toContain('<x-forms.button')
->toContain('Back up now</x-forms.button>')
->not->toContain('class="icon-button shrink-0"')
->toContain('defaultClass="icon-button shrink-0"')
->toContain('aria-label="Edit backup schedule"')
->toContain('<x-reicon name="settings" class="size-4" />')
->not->toContain('>Settings</x-forms.button>')
->not->toContain('class="contents cursor-pointer"');
expect($styles)
+123 -17
View File
@@ -277,14 +277,15 @@ it('opens address fields and service-wide redirects in the same settings dialog
$html = $component->call('startEdit', $index)
->assertSet('editingDomain', $domain)
->assertSee('Domain settings')
->assertSee('Save changes')
->assertSee('Save')
->assertSee('Regenerate hostname')
->assertDontSee('Save address')
->assertSee('Search engine indexing')
->assertSee('www redirect')
->assertDontSee('Edit address and port')
->html();
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1);
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0);
}
});
@@ -297,6 +298,16 @@ it('uses segmented fields when adding and editing service domains', function ()
->not->toContain('placeholder="https://app.example.com"');
});
it('matches the application domains toolbar heading and top spacing', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)
->toContain('<div class="flex flex-wrap items-center gap-2">')
->toContain('<h2 id="domains-section">Domains</h2>')
->not->toContain('<div class="mt-2 flex flex-wrap items-center gap-2">')
->not->toContain('<h3>Domains</h3>');
});
it('resets the add domain dns gate when segmented domain fields change', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('addDomainDnsFailed', true)
@@ -418,7 +429,7 @@ it('saves the explicitly selected service redirect value', function () {
->call('updateServiceRedirect', $this->webApp->id, 'www')
->assertDispatched('success')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
->assertSet('domainRows', fn (array $rows): bool => filled(collect($rows)->firstWhere('url', 'https://www.web.example.com')['checked_at'] ?? null))
->assertSet('domainRows', fn (array $rows): bool => (collect($rows)->firstWhere('url', 'https://www.web.example.com')['dns_status'] ?? null) === 'checking')
->assertSee('https://www.web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('www');
@@ -935,13 +946,10 @@ it('updates search engine indexing from the service domains view', function () {
->assertSee('Indexable')
->assertSee('Search engine indexing')
->assertSee('www redirect')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateServiceRedirect', false)
->assertSee('wire:ignore', false)
->assertDontSee('x-model="localIndexing"', false)
->assertDontSee('x-model="localDirection"', false)
->assertDontSee('@js(', false)
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
->assertSee('editingIndexing', false)
->assertDontSee('toggleNoindexDomain', false)
->set('editingIndexing', 'noindex')
->call('updateDomain')
->assertDispatched('configurationChanged')
->assertDispatched('success')
->assertSet('service', fn (Service $service): bool => $service->applications
@@ -955,6 +963,70 @@ it('updates search engine indexing from the service domains view', function () {
->not->toContain('<select');
});
it('regenerates a service application domain only when the modal is saved', function () {
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->set('editingIndexing', 'noindex')
->call('regenerateEditingDomain');
$generatedHost = $component->get('editingDomainParts')['host'];
expect($generatedHost)->not->toBe('api.example.com')
->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com');
$component->call('updateDomain')->assertHasNoErrors();
expect($this->apiApp->fresh()->fqdn)->toBe("https://{$generatedHost}")
->and($this->apiApp->fresh()->noindexDomains()->all())->toBe(["https://{$generatedHost}"]);
});
it('starts a dns check after a manually edited service domain is saved', function () {
Queue::fake();
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = true;
$settings->save();
$this->apiApp->update(['fqdn' => 'https://api.example.com:81']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '81')
->set('editingDomainParts.host', 'manual-service.example.com')
->call('updateDomain')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
fn (array $row): bool => $row['url'] === 'https://manual-service.example.com' && $row['dns_status'] === 'checking'
));
expect($this->apiApp->fresh()->domain_dns_statuses['https://manual-service.example.com']['status'] ?? null)->toBe('checking');
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual-service.example.com'
&& $job->statusKey === 'https://manual-service.example.com');
});
it('does not start a dns check when only service domain settings change', function () {
Queue::fake();
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->set('editingIndexing', 'noindex')
->call('updateDomain')
->assertHasNoErrors();
Queue::assertNotPushed(CheckDomainDnsJob::class);
});
it('checks the counterpart added by a service domain redirect change', function () {
Queue::fake();
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->set('editingRedirect', 'www')
->call('updateDomain')
->assertHasNoErrors();
expect(explode(',', (string) $this->apiApp->fresh()->fqdn))->toContain('https://www.api.example.com');
Queue::assertPushed(CheckDomainDnsJob::class, 1);
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://www.api.example.com');
});
it('keeps noindex domains when normalizing a custom service domain port', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com:8080',
@@ -980,9 +1052,18 @@ it('shows an inherited internal port badge from the coolify service env port', f
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.internal_port', 3000)
->assertSet('domainRows.0.has_port_override', false)
->set('dnsValidationEnabled', false)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '3000')
->call('updateDomain')
->assertHasNoErrors()
->assertSee('Internal port 3000')
->assertSee('Inherited from the Coolify service port', false)
->assertDontSee('No internal port');
expect($this->apiApp->fresh())
->fqdn->toBe('https://api.example.com')
->domain_port_overrides->toBeNull();
});
it('shows a custom internal port badge for a service domain override', function () {
@@ -1025,7 +1106,7 @@ it('prioritizes public addresses and moves domain configuration behind settings'
->assertSee('Add domain')
->assertSee('Domain settings')
->call('startEdit', 0)
->assertSee('Indexing and redirect changes save automatically.')
->assertDontSee('Indexing and redirect changes save automatically.')
->assertSee('Internal port 8080')
->assertSee('Both www and non-www')
->assertSee('Search indexing allowed')
@@ -1094,15 +1175,40 @@ it('uses the shared mobile domain summary layout', function () {
->toContain('Noindex');
});
it('reuses the floating save bar for pending domain address edits', function () {
it('uses explicit modal actions for pending domain edits', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('<x-unsaved-bar action="updateDomain"')
->toContain('dirty="hasAddressChanges"')
->toContain('<template x-if="modalOpen">')
->not->toContain('Save address');
expect($view)->not->toContain('<x-unsaved-bar action="updateDomain"')
->toContain('wire:click="regenerateEditingDomain"')
->toContain('wire:click="updateDomain"')
->toContain('Save');
});
it('opens service domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
});
it('uses the dns badge as progress for single and all service checks', function (string $action, array $parameters) {
Queue::fake();
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call($action, ...$parameters)
->assertSet('domainRows.0.dns_status', 'checking')
->assertSee('Checking DNS...')
->assertSeeHtml('loading-indicator');
Queue::assertPushed(CheckDomainDnsJob::class);
})->with([
'single domain' => ['checkDomainDns', [0]],
'all domains' => ['checkAllDns', []],
]);
it('inherits the counterpart internal port when enabling redirects without a port warning', function (?int $override, string $redirect) {
$this->service->update([
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_URL_WEB_80\n api:\n image: node:alpine\n",
@@ -1211,5 +1317,5 @@ it('renders each service domain group as a separate card', function () {
it('lays out the domain settings dropdowns in responsive columns', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2')
->toContain('class="sm:col-span-2 text-[12px]');
->toContain('flex flex-wrap items-center justify-between gap-2');
});
@@ -0,0 +1,133 @@
<?php
use App\Livewire\Project\Service\Index;
use App\Livewire\Project\Service\ResourceCard;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0]);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->application = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'web',
'service_id' => $this->service->id,
'image' => 'example/web:latest',
'status' => 'exited',
]);
$this->database = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'postgres',
'service_id' => $this->service->id,
'image' => 'postgres:17',
'status' => 'exited',
]);
$this->parameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $this->service->uuid,
];
});
it('shows application general and advanced settings in a resource card modal', function () {
Livewire::test(ResourceCard::class, [
'service' => $this->service,
'resource' => $this->application,
'parameters' => $this->parameters,
])
->assertSee('Resource settings')
->assertSeeHtml('data-icon-tooltip-ignore')
->assertSeeHtml('underline underline-offset-4')
->assertDontSeeHtml('decoration-dotted')
->assertDontSee(route('project.service.index', [
...$this->parameters,
'stack_service_uuid' => $this->application->uuid,
]), false);
Livewire::test(Index::class, [
'serviceApplication' => $this->application,
'embedded' => true,
])
->set('requiredPort', 80)
->assertSee('Save changes')
->assertDontSee('Required Port: 80')
->assertSeeHtml('data-domain-summary')
->assertSeeHtml('data-service-resource-actions')
->assertSeeInOrder(['Delete', 'Convert to Database', 'Save changes'])
->assertDontSeeHtml('<h2>General</h2>')
->assertDontSeeHtml('<h2>Advanced</h2>')
->assertDontSee("You have changes that haven't been saved yet.");
});
it('shows database general and advanced settings in a resource card modal', function () {
Livewire::test(ResourceCard::class, [
'service' => $this->service,
'resource' => $this->database,
'parameters' => $this->parameters,
])
->assertSee('Resource settings')
->assertSeeLivewire(Index::class)
->assertSeeHtml('data-icon-tooltip-ignore')
->assertSeeHtml('underline underline-offset-4')
->assertDontSeeHtml('decoration-dotted')
->assertDontSee(route('project.service.index', [
...$this->parameters,
'stack_service_uuid' => $this->database->uuid,
]), false);
Livewire::test(Index::class, [
'serviceApplication' => $this->database,
'embedded' => true,
])
->assertSee('Public access')
->assertSee('Log drain')
->assertSee('Save changes')
->assertSeeHtml('data-service-resource-actions')
->assertSeeInOrder(['Delete', 'Convert to Application', 'Save changes'])
->assertDontSeeHtml('<h2>General</h2>')
->assertDontSeeHtml('<h2>Advanced</h2>')
->assertDontSee("You have changes that haven't been saved yet.");
});
it('redirects old resource settings links to the service configuration page', function (string $routeName, string $resourceUuid) {
$this->get(route($routeName, [
...$this->parameters,
'stack_service_uuid' => $resourceUuid,
]))->assertRedirect(route('project.service.configuration', $this->parameters));
})->with([
'application general' => fn () => ['project.service.index', $this->application->uuid],
'application advanced' => fn () => ['project.service.index.advanced', $this->application->uuid],
'database general' => fn () => ['project.service.index', $this->database->uuid],
'database advanced' => fn () => ['project.service.index.advanced', $this->database->uuid],
]);
@@ -27,8 +27,10 @@ it('initializes persisted sidebar state before enabling layout transitions', fun
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain("hasSidebarPreference: localStorage.getItem('sidebarCollapsed') !== null")
->toContain("userCollapsed: localStorage.getItem('sidebarCollapsed') === 'true'")
->toContain('return this.userCollapsed || (this.autoCollapse && this.hasSecondBar);')
->toContain('return this.hasSidebarPreference ? this.userCollapsed : (this.autoCollapse && this.hasSecondBar);')
->toContain('this.hasSidebarPreference = true;')
->toContain('this.userCollapsed = this.collapsed;')
->toContain("localStorage.setItem('sidebarCollapsed', this.userCollapsed);")
->toContain('sidebarReady: false')
@@ -46,6 +46,14 @@ it('uses bordered status badges in the top breadcrumb', function () {
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
});
it('keeps compound resource statuses on one line in the top breadcrumb', function () {
foreach (['application', 'database', 'service'] as $resourceType) {
$status = file_get_contents(resource_path("views/livewire/project/{$resourceType}/status.blade.php"));
expect($status)->toContain('class="flex items-center gap-1"');
}
});
it('renders resource statuses through reactive livewire components', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
@@ -47,3 +47,38 @@ YAML,
expect($labels->values()->all())->toContain("traefik.docker.network={$service->uuid}");
});
it('labels an existing service application from its stored type after its image changes', function () {
Bus::fake();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => <<<'YAML'
services:
app:
image: postgres:17
YAML,
]);
$serviceApplication = ServiceApplication::create([
'name' => 'app',
'service_id' => $service->id,
'image' => 'nginx:latest',
]);
$parsedCompose = serviceParser($service);
$labels = collect(data_get($parsedCompose, 'services.app.labels'));
expect($serviceApplication->fresh()->image)->toBe('postgres:17')
->and($labels->values()->all())
->toContain('coolify.service.subType=application')
->not->toContain('coolify.service.subType=database');
});
+28
View File
@@ -0,0 +1,28 @@
<?php
it('uses the shared button-height view toggle on every list and grid page', function () {
$paths = [
'views/components/shared-variables/view-controls.blade.php',
'views/livewire/project/index.blade.php',
'views/livewire/project/resource/index.blade.php',
'views/livewire/project/service/configuration.blade.php',
'views/livewire/project/show.blade.php',
'views/livewire/server/index.blade.php',
'views/livewire/shared/list-search-controls.blade.php',
'views/livewire/tags/show.blade.php',
];
foreach ($paths as $path) {
$contents = file_get_contents(resource_path($path));
expect($contents)
->toContain('class="view-toggle')
->not->toContain('class="flex h-9 items-center rounded-lg border');
}
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)
->toContain('@utility view-toggle')
->toContain('h-8');
});
+44 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Mcp\Concerns\ResolvesResource;
use App\Models\Server;
it('normalizes requested log line counts', function () {
@@ -12,12 +13,44 @@ it('normalizes requested log line counts', function () {
expect(normalizeLogLines(null))->toBe(100)
->and(normalizeLogLines(''))->toBe(100)
->and(normalizeLogLines('abc'))->toBe(100)
->and(normalizeLogLines('0'))->toBe(100)
->and(normalizeLogLines('all'))->toBe('all')
->and(normalizeLogLines('ALL'))->toBe(100)
->and(normalizeLogLines('-1'))->toBe('all')
->and(normalizeLogLines('0'))->toBe(0)
->and(normalizeLogLines('-5'))->toBe(100)
->and(normalizeLogLines('50'))->toBe(50)
->and(normalizeLogLines('50000'))->toBe(10000);
});
it('keeps MCP log requests bounded', function () {
$normalizer = new class
{
use ResolvesResource;
public function normalize(mixed $lines): int
{
return $this->normalizeMcpLogLines($lines);
}
};
expect($normalizer->normalize('all'))->toBe(100)
->and($normalizer->normalize('-1'))->toBe(100)
->and($normalizer->normalize('0'))->toBe(100)
->and($normalizer->normalize('501'))->toBe(500);
});
it('documents the named all logs option on every REST log endpoint', function (string $controller) {
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
expect($source)->toContain('Use `all` to return all logs. `-1` remains available as a compatibility alias.');
})->with([
'applications' => 'ApplicationsController',
'databases' => 'DatabasesController',
'services' => 'ServicesController',
'service applications' => 'ServiceApplicationsController',
'service databases' => 'ServiceDatabasesController',
]);
it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
@@ -63,6 +96,16 @@ it('builds docker log commands with options before an escaped container id', fun
->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
});
it('builds docker log commands for all and zero lines', function () {
$server = new Server;
$server->settings = ['is_swarm_manager' => false];
expect(buildContainerLogsCommand($server, 'container-1', 'all'))
->toBe("docker logs -n all 'container-1' 2>&1")
->and(buildContainerLogsCommand($server, 'container-1', 0))
->toBe("docker logs -n 0 'container-1' 2>&1");
});
it('builds swarm service log commands with options before an escaped service id', function () {
if (! function_exists('buildContainerLogsCommand')) {
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
@@ -44,6 +44,7 @@ function coolifyVariablesForFqdn(string $fqdn, string $composeParsingVersion = '
// The created hook resets this, so it has to be set afterwards.
$application->compose_parsing_version = $composeParsingVersion;
$application->save();
Application::withoutGlobalScopes()->whereKey($application->id)->update(['fqdn' => $fqdn]);
$job = new TestableCoolifyUrlDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
@@ -92,3 +93,19 @@ it('still resolves a single domain', function () {
->toContain("COOLIFY_URL='https://a.example.com'")
->toContain("COOLIFY_FQDN='a.example.com'");
});
it('ignores a hostless stored domain while setting deployment variables', function () {
$variables = coolifyVariablesForFqdn('https://,https://a.example.com');
expect($variables)
->toContain("COOLIFY_URL='https://a.example.com'")
->toContain("COOLIFY_FQDN='a.example.com'");
});
it('does not set domain variables for a hostless stored fqdn', function () {
$variables = coolifyVariablesForFqdn('https://');
expect($variables)
->not->toContain('COOLIFY_URL=')
->not->toContain('COOLIFY_FQDN=');
});
@@ -0,0 +1,51 @@
<?php
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
use Tests\TestCase;
uses(TestCase::class);
it('parses valid Docker Compose files with more than 128 collection aliases', function () {
Process::fake();
$services = collect(range(1, 129))
->mapWithKeys(fn (int $index): array => ["service-{$index}" => [
'<<' => '*defaults',
]])
->map(fn (array $service, string $name): string => " {$name}:\n <<: {$service['<<']}\n")
->implode('');
$application = new Application;
$application->docker_compose_raw = "x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}";
$server = new Server;
$server->ip = '127.0.0.1';
$server->user = 'root';
$destination = new StandaloneDocker;
$destination->setRelation('server', $server);
$application->setRelation('destination', $destination);
$application->oldRawParser();
$parsedCompose = Yaml::parse($application->docker_compose_raw);
expect(data_get($parsedCompose, 'services'))->toHaveCount(129)
->and(data_get($parsedCompose, 'services.service-129.image'))->toBe('alpine:latest')
->and(data_get($parsedCompose, 'services.service-129.labels'))->toContain('coolify.managed=true');
});
it('keeps a finite collection alias limit for Docker Compose files', function () {
$services = collect(range(1, Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES + 1))
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
->implode('');
$application = new Application;
$application->docker_compose_raw = "x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}";
$application->oldRawParser();
})->throws(RuntimeException::class, 'Maximum number of collection aliases (256) exceeded');
@@ -0,0 +1,98 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
class TestableDockerComposeImagePullDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public array $commandServers = [];
public bool $failCommands = false;
public function __construct() {}
public function execute_remote_command(...$commands): void
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$this->commandServers[] = $reflection->getProperty('server')->getValue($this);
$this->recordedCommands[] = $commands;
if ($this->failCommands) {
throw new RuntimeException('Image pull failed.');
}
}
}
function makeDockerComposeImagePullJob(bool $useBuildServer = false): array
{
$job = new TestableDockerComposeImagePullDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$application = Mockery::mock(Application::class)->makePartial();
$application->uuid = 'application-uuid';
$application->shouldReceive('workdir')->andReturn('/runtime/application');
$queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
$queue->shouldReceive('addLogEntry')->once()->andReturnNull();
$buildServer = new Server;
$buildServer->id = 1;
$mainServer = new Server;
$mainServer->id = 2;
foreach ([
'application' => $application,
'application_deployment_queue' => $queue,
'server' => $buildServer,
'build_server' => $buildServer,
'mainServer' => $mainServer,
'use_build_server' => $useBuildServer,
'workdir' => '/artifacts/application',
'deployment_uuid' => 'deployment-uuid',
'docker_compose_location' => '/docker-compose.yaml',
'coolify_variables' => '',
] as $property => $value) {
$reflection->getProperty($property)->setValue($job, $value);
}
return [$job, $reflection, $buildServer, $mainServer];
}
it('pulls image-only compose services before removing running containers', function () {
$source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$methodStart = strpos($source, 'private function deploy_docker_compose_buildpack()');
$methodEnd = strpos($source, 'private function pull_docker_compose_images()', $methodStart);
$deploymentMethod = substr($source, $methodStart, $methodEnd - $methodStart);
$pullPosition = strpos($deploymentMethod, '$this->pull_docker_compose_images();');
$stopPosition = strpos($deploymentMethod, '$this->stop_running_container(force: true);');
expect($pullPosition)->not->toBeFalse()
->and($stopPosition)->not->toBeFalse()
->and($pullPosition)->toBeLessThan($stopPosition);
});
it('aborts the deployment when pulling a compose image fails', function () {
[$job, $reflection] = makeDockerComposeImagePullJob();
$job->failCommands = true;
$reflection->getMethod('pull_docker_compose_images')->invoke($job);
})->throws(RuntimeException::class, 'Image pull failed.');
it('pulls compose images on the runtime server when using a build server', function () {
[$job, $reflection, $buildServer, $mainServer] = makeDockerComposeImagePullJob(useBuildServer: true);
$reflection->getMethod('pull_docker_compose_images')->invoke($job);
$command = $job->recordedCommands[0][0][0];
expect($job->commandServers)->toHaveCount(1)
->and($job->commandServers[0])->toBe($mainServer)
->and($job->commandServers[0])->not->toBe($buildServer)
->and($command)->toContain('pull --ignore-buildable')
->and($command)->toContain('--env-file /runtime/application/.env');
});
+22
View File
@@ -34,3 +34,25 @@ it('keeps an explicit override on the paired domain', function (string $source,
'www redirect' => ['https://example.com', 'https://www.example.com'],
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
]);
it('drops hostless domains and their port overrides', function () {
$result = DomainPortOverrides::normalize(
'https://,https://example.com',
[
'https://' => 3000,
'https://example.com' => 8080,
],
);
expect($result)->toBe([
'fqdn' => 'https://example.com',
'overrides' => ['https://example.com' => 8080],
]);
});
it('clears an fqdn that contains only a hostless domain', function () {
expect(DomainPortOverrides::normalize('https://', ['https://' => 3000]))->toBe([
'fqdn' => null,
'overrides' => null,
]);
});
+12 -11
View File
@@ -1,20 +1,21 @@
<?php
it('pins the same MinIO client release in every Coolify image', function () {
$dockerfiles = [
it('pins the AIStor MinIO client release in every Coolify image', function () {
$imageFiles = [
dirname(__DIR__, 2).'/docker/production/Dockerfile',
dirname(__DIR__, 2).'/docker/development/Dockerfile',
dirname(__DIR__, 2).'/docker/coolify-helper/Dockerfile',
dirname(__DIR__, 2).'/docker-compose.dev.yml',
dirname(__DIR__, 2).'/docker-compose.dev-multi.yml',
dirname(__DIR__, 2).'/docker-compose-maxio.dev.yml',
];
$versions = collect($dockerfiles)->map(function (string $dockerfile): string {
$contents = file_get_contents($dockerfile);
foreach ($imageFiles as $imageFile) {
$contents = file_get_contents($imageFile);
expect(preg_match('/^ARG MINIO_VERSION=(.+)$/m', $contents, $matches))->toBe(1);
return $matches[1];
});
expect($versions->unique()->values()->all())
->toBe(['RELEASE.2025-08-13T08-35-41Z']);
expect($contents)
->toContain('quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z')
->not->toContain('quay.io/minio/aistor/mc:latest')
->not->toContain('minio/mc:');
}
});

Some files were not shown because too many files have changed in this diff Show More