mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 04:18:34 +00:00
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:
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"');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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=');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user