feat(ui): Shadow UI redesign, domain management, and DNS autoconfigure (#11119)

Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
This commit is contained in:
Andras BacsaiandShadowArcanist authored and GitHub committed 2026-08-03 23:11:54 +02:00
1 parent 0b843bb07c
commit 8ae56587d5
497 files changed
+44045 -22630

No files matched your search

@@ -56,11 +56,10 @@ it('shows explicit loading spinners and disables cloud token action buttons whil
$tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php');
$serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2)
->and(substr_count($tokenFormView, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
->and($serverTokenView)->toContain('wire:target="validateToken"')
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
expect($tokenFormView)->toContain('wire:target="addToken"')
->and($tokenFormView)->toContain('<x-forms.button type="submit"')
->and($tokenShowView)->toContain('wire:click="validateToken"')
->and($tokenShowView)->toContain('<x-forms.button type="button" wire:click="validateToken">')
->and($serverTokenView)->toContain('wire:click.prevent="validateToken"')
->and($serverTokenView)->toContain('<x-forms.button canGate="update" :canResource="$server"');
});
+22
View File
@@ -0,0 +1,22 @@
<?php
it('returns zero for null or empty fqdn', function () {
expect(countDomains(null))->toBe(0)
->and(countDomains(''))->toBe(0)
->and(countDomains(' '))->toBe(0);
});
it('counts a single domain', function () {
expect(countDomains('https://app.coolify.io'))->toBe(1)
->and(countDomains(' https://app.coolify.io '))->toBe(1);
});
it('counts multiple comma-separated domains', function () {
expect(countDomains('https://app.coolify.io,https://cloud.coolify.io/dashboard'))->toBe(2)
->and(countDomains('https://a.com, https://b.com , https://c.com'))->toBe(3);
});
it('ignores empty segments between commas', function () {
expect(countDomains('https://a.com,,https://b.com,'))->toBe(2)
->and(countDomains(',,,'))->toBe(0);
});
+48
View File
@@ -0,0 +1,48 @@
<?php
it('returns A for ipv4 addresses', function () {
expect(dnsRecordTypeForIp('203.0.113.10'))->toBe('A')
->and(dnsRecordTypeForIp('172.16.0.3 (coolify-testing-host)'))->toBe('A');
});
it('returns AAAA for ipv6 addresses', function () {
expect(dnsRecordTypeForIp('2001:db8::1'))->toBe('AAAA')
->and(dnsRecordTypeForIp('[2001:db8::1]'))->toBe('AAAA')
->and(dnsRecordTypeForIp('2001:db8::1 (my-host)'))->toBe('AAAA');
});
it('defaults to A when the address is missing or not an ip', function () {
expect(dnsRecordTypeForIp(null))->toBe('A')
->and(dnsRecordTypeForIp(''))->toBe('A')
->and(dnsRecordTypeForIp('coolify-testing-host'))->toBe('A');
});
it('builds a short A-record guidance message for ipv4 targets', function () {
$message = dnsMismatchGuidanceMessage('172.16.0.3 (coolify-testing-host)', '172.16.0.3');
expect($message)->toBe('A record → 172.16.0.3')
->and($message)->not->toContain('—')
->and($message)->not->toContain('continue');
});
it('builds a short AAAA-record guidance message for ipv6 targets', function () {
$message = dnsMismatchGuidanceMessage('2001:db8::1', '2001:db8::1');
expect($message)->toBe('AAAA record → 2001:db8::1')
->and($message)->not->toContain('—')
->and($message)->not->toContain('continue');
});
it('prefers the bare ip over a hostname label', function () {
expect(dnsMismatchGuidanceMessage('coolify-testing-host', '172.16.0.3'))
->toBe('A record → 172.16.0.3');
});
it('falls back when no target is available', function () {
expect(dnsMismatchGuidanceMessage(null))->toBe('DNS validation failed. Check your DNS records.');
});
it('accepts Cloudflare ipv6 proxy addresses', function () {
expect(isCloudflareIp('2606:4700:3037::6815:4f2b'))->toBeTrue()
->and(isCloudflareIp('2001:db8::1'))->toBeFalse();
});
+95
View File
@@ -0,0 +1,95 @@
<?php
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class);
it('returns a literal ipv4 address unchanged', function () {
$result = resolveServerIpAddress('203.0.113.10');
expect($result['ip'])->toBe('203.0.113.10')
->and($result['configured'])->toBe('203.0.113.10')
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('returns a literal ipv6 address unchanged', function () {
$result = resolveServerIpAddress('2001:db8::1');
expect($result['ip'])->toBe('2001:db8::1')
->and($result['configured'])->toBe('2001:db8::1')
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('strips brackets from ipv6 addresses', function () {
$result = resolveServerIpAddress('[2001:db8::1]');
expect($result['ip'])->toBe('2001:db8::1')
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('resolves localhost hostname to a real ip address', function () {
$result = resolveServerIpAddress('localhost');
expect($result['configured'])->toBe('localhost')
->and($result['resolved_from_hostname'])->toBeTrue()
->and($result['ip'])->not->toBeNull()
->and(filter_var($result['ip'], FILTER_VALIDATE_IP))->not->toBeFalse();
});
it('returns nulls for empty input', function () {
expect(resolveServerIpAddress(null))->toMatchArray([
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
])->and(resolveServerIpAddress(''))->toMatchArray([
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
]);
});
it('keeps configured hostname when it cannot be resolved', function () {
$host = 'this-hostname-should-not-resolve-'.bin2hex(random_bytes(8)).'.invalid';
$result = resolveServerIpAddress($host);
expect($result['configured'])->toBe($host)
->and($result['ip'])->toBeNull()
->and($result['resolved_from_hostname'])->toBeFalse();
});
it('caches successful hostname resolutions for subsequent lookups', function () {
Cache::flush();
$result = resolveServerIpAddress('localhost');
expect($result['ip'])->not->toBeNull()
->and(Cache::get('server-ip-resolve:localhost'))->toBe($result['ip']);
// A fresh cache hit must be preferred over re-querying DNS.
Cache::put('server-ip-resolve:localhost', '203.0.113.99', 60);
$cached = resolveServerIpAddress('localhost');
expect($cached['ip'])->toBe('203.0.113.99')
->and($cached['configured'])->toBe('localhost')
->and($cached['resolved_from_hostname'])->toBeTrue();
});
it('does not cache failed hostname resolutions', function () {
Cache::flush();
$host = 'this-hostname-should-not-resolve-'.bin2hex(random_bytes(8)).'.invalid';
resolveServerIpAddress($host);
expect(Cache::get('server-ip-resolve:'.mb_strtolower($host)))->toBeNull();
});
it('does not use the cache for literal ip addresses', function () {
Cache::put('server-ip-resolve:203.0.113.10', '198.51.100.1', 60);
$result = resolveServerIpAddress('203.0.113.10');
expect($result['ip'])->toBe('203.0.113.10')
->and($result['resolved_from_hostname'])->toBeFalse();
});
@@ -0,0 +1,33 @@
<?php
/**
* Runtime Logs must query Docker with the label that matches the resource type.
* Applications use coolify.applicationId, services coolify.serviceId, databases coolify.databaseId.
* Using the application helper for services/databases returns empty results even when containers run.
*/
it('looks up containers with the correct helper for each resource type', function () {
$logsFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Shared/Logs.php');
expect($logsFile)
->toContain('getCurrentApplicationContainerStatus(')
->toContain('getCurrentServiceContainerStatus(')
->toContain('getCurrentDatabaseContainerStatus(')
->toContain('$this->resource instanceof Application')
->toContain('$this->resource instanceof Service');
});
it('filters application containers by coolify.applicationId', function () {
$dockerHelpers = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
expect($dockerHelpers)->toContain('label=coolify.applicationId=');
});
it('filters service and database containers by the correct coolify labels', function () {
$dockerHelpers = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
expect($dockerHelpers)
->toContain('function getCurrentServiceContainerStatus')
->toContain('label=coolify.serviceId=')
->toContain('function getCurrentDatabaseContainerStatus')
->toContain('label=coolify.databaseId=');
});
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Regression for #11079 / #11030: clearing domains normalizes to null, then
* sslipDomainWarning must accept null without a TypeError.
*/
it('returns false when domains are null', function () {
expect(sslipDomainWarning(null))->toBeFalse();
});
it('returns false when domains are empty or whitespace', function () {
expect(sslipDomainWarning(''))->toBeFalse();
expect(sslipDomainWarning(' '))->toBeFalse();
});
it('returns false for non-sslip https domains', function () {
expect(sslipDomainWarning('https://example.com'))->toBeFalse();
expect(sslipDomainWarning('http://app.example.com,https://www.example.com'))->toBeFalse();
});
it('returns false for http sslip domains without https', function () {
expect(sslipDomainWarning('http://app.127.0.0.1.sslip.io'))->toBeFalse();
});
it('returns true when any domain uses https with sslip', function () {
expect(sslipDomainWarning('https://app.127.0.0.1.sslip.io'))->toBeTrue();
expect(sslipDomainWarning('https://example.com,https://app.127.0.0.1.sslip.io'))->toBeTrue();
});