diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 6350a5f37..cec90288e 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -23,13 +23,10 @@ class StartSentinel $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); $token = $server->settings->ensureValidSentinelToken(); - $endpoint = data_get($server, 'settings.sentinel_custom_url'); + $endpoint = $server->settings->ensureSentinelUrl(); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $mountDir = '/data/coolify/sentinel'; $image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version; - if (! $endpoint) { - throw new \RuntimeException('You should set FQDN in Instance Settings.'); - } $environments = [ 'TOKEN' => $token, 'DEBUG' => $debug ? 'true' : 'false', diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index 909ed54f9..cd05002aa 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -114,9 +114,10 @@ class Sentinel extends Component return; } - $this->isSentinelEnabled = true; $customImage = isDev() ? $this->sentinelCustomDockerImage : null; StartSentinel::run($this->server, true, null, $customImage); + $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; + $this->isSentinelEnabled = true; } else { $this->isSentinelEnabled = false; $this->isMetricsEnabled = false; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c7181ebcf..9e6108de0 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -39,6 +39,8 @@ class ValidateAndInstall extends Component public bool $ask = false; + public bool $isInstalling = false; + protected $listeners = [ 'init', 'validateConnection', @@ -51,6 +53,7 @@ class ValidateAndInstall extends Component public function init(int $data = 0) { + $this->isInstalling = false; $this->uptime = null; $this->supported_os_type = null; $this->prerequisites_installed = null; @@ -172,6 +175,7 @@ class ValidateAndInstall extends Component if ($this->number_of_tries <= $this->max_tries) { $this->installationStep = 'Prerequisites'; $activity = $this->server->installPrerequisites(); + $this->isInstalling = true; $this->number_of_tries++; $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs"); } @@ -208,6 +212,7 @@ class ValidateAndInstall extends Component if ($this->number_of_tries <= $this->max_tries) { $this->installationStep = 'Docker'; $activity = $this->server->installDocker(); + $this->isInstalling = true; $this->number_of_tries++; $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs"); } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 0453dc793..3bd39aeb1 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -219,7 +219,22 @@ class ServerSetting extends Model return $token; } - public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false) + public function ensureSentinelUrl(): string + { + $url = $this->sentinel_custom_url; + + if (blank($url)) { + $url = $this->generateSentinelUrl(ignoreEvent: true); + } + + if (blank($url)) { + throw new \RuntimeException('Set an instance FQDN or public IP before enabling Sentinel.'); + } + + return $url; + } + + public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string { $domain = null; $settings = InstanceSettings::get(); diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 84522a5e1..fee3f376f 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -253,7 +253,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d } catch (Exception) { $timestamp->setTimezone('UTC'); } - data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u')); + data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s')); return $i; }) diff --git a/resources/views/livewire/activity-monitor.blade.php b/resources/views/livewire/activity-monitor.blade.php index f3e79db0e..495d5cf52 100644 --- a/resources/views/livewire/activity-monitor.blade.php +++ b/resources/views/livewire/activity-monitor.blade.php @@ -1,7 +1,7 @@ @php use App\Actions\CoolifyTask\RunRemoteProcess; @endphp
$fullHeight, - 'h-full overflow-hidden' => !$fullHeight, + 'overflow-hidden' => !$fullHeight, ])> @if ($activity) @if (isset($header)) diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php index 62048602f..dca26797d 100644 --- a/resources/views/livewire/boarding/index.blade.php +++ b/resources/views/livewire/boarding/index.blade.php @@ -507,16 +507,16 @@ @endif - + Server validation - Start validation - +
@@ -659,7 +659,7 @@ @if ($currentState !== 'welcome' && $currentState !== 'create-resource') -
+
- - - - @endcan - @endif +
+

New server

+
+ @if ($selectedType) + + Change method + + @endif + @if ($selectedType && $selectedType !== 'manual' && ! $selectedTokenUuid) + @php + $tokenProvider = $selectedType === 'digital-ocean' ? 'digitalocean' : $selectedType; + $tokenProviderName = $selectedType === 'digital-ocean' + ? 'DigitalOcean' + : str($selectedType)->headline(); + @endphp + @can('create', App\Models\CloudProviderToken::class) + + + + + + + @endcan + @endif +
@if (!$selectedType)
- +
+
@can('viewAny', App\Models\CloudProviderToken::class)
- +
+
@else
diff --git a/resources/views/livewire/server/new/by-ip.blade.php b/resources/views/livewire/server/new/by-ip.blade.php index d7fe02178..95e674ab5 100644 --- a/resources/views/livewire/server/new/by-ip.blade.php +++ b/resources/views/livewire/server/new/by-ip.blade.php @@ -20,28 +20,17 @@ -
- - -
- -
+
- -
-
- - -
- - +
+
+
+ +
@can('create', App\Models\PrivateKey::class)
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+
@endif diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php index 2354b79cd..e5041fb4b 100644 --- a/resources/views/livewire/server/sentinel.blade.php +++ b/resources/views/livewire/server/sentinel.blade.php @@ -13,19 +13,14 @@ helper="Monitor server and container health while collecting historical metrics.">
- @if (!$isSentinelEnabled) Enable Sentinel @else + diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index c3e6a35b3..dbe82c5ee 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -192,17 +192,17 @@
@endif - + Validate and configure - - + + {{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }} - +
@if ($this->limaStartCommand) @@ -261,10 +261,11 @@ @if ($isBuildServerLocked) + label="Use as a dedicated build server" /> @else @endif
diff --git a/resources/views/livewire/server/validate-and-install.blade.php b/resources/views/livewire/server/validate-and-install.blade.php index 0bba6652c..09dc7aec6 100644 --- a/resources/views/livewire/server/validate-and-install.blade.php +++ b/resources/views/livewire/server/validate-and-install.blade.php @@ -21,6 +21,12 @@ $showDocker = (bool) ($uptime && $supported_os_type && $prerequisites_installed); $showCompose = $showDocker; $showVersion = (bool) ($showDocker && $docker_compose_installed); + $validationComplete = (bool) ($uptime + && $supported_os_type + && $prerequisites_installed + && $docker_installed + && $docker_compose_installed + && $docker_version); $checkpoints = [ [ @@ -66,7 +72,7 @@ ]; @endphp -
+
@if ($ask)
@@ -77,28 +83,36 @@ Continue @else -
-
-
-

Validation checkpoints

-
-
-
-
- @foreach ($checkpoints as $checkpoint) - @continue(! $checkpoint['visible']) - - @endforeach -
+
+
+

Validation checkpoints

-
+
+ @foreach ($checkpoints as $checkpoint) + + @endforeach +
+
-
-
- + @if ($validationComplete) +
+
+ + Validation complete +
+ + Close +
-
+ @elseif ($isInstalling) +
+
+ +
+
+ @endif @isset($error)
toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"'); +}); + +test('server validation opens in the centered process dialog', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('@click="processDialogOpen = true"') + ->not->toContain(''); +}); diff --git a/tests/Feature/Livewire/SentinelComponentTest.php b/tests/Feature/Livewire/SentinelComponentTest.php index 47cad4e22..41b3f2b49 100644 --- a/tests/Feature/Livewire/SentinelComponentTest.php +++ b/tests/Feature/Livewire/SentinelComponentTest.php @@ -19,3 +19,20 @@ it('dispatches a server navbar refresh after toggling sentinel', function () { expect($matches['body'] ?? '') ->toContain("\$this->dispatch('refreshServerShow');"); }); + +it('only marks sentinel enabled after startup succeeds', function () { + $componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php')); + + preg_match('/public function toggleSentinel\([^)]*\).*?\{(?.*?)\n \}/s', $componentSource, $matches); + $toggleBody = $matches['body'] ?? ''; + + expect(strpos($toggleBody, 'StartSentinel::run'))->toBeLessThan( + strpos($toggleBody, '$this->isSentinelEnabled = true;') + ); +}); + +it('does not repeat a disabled status badge in the sentinel empty state', function () { + $view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php')); + + expect($view)->not->toContain("? 'Disabled'"); +}); diff --git a/tests/Feature/ServerBuildRoleHelperTest.php b/tests/Feature/ServerBuildRoleHelperTest.php new file mode 100644 index 000000000..3cb85e608 --- /dev/null +++ b/tests/Feature/ServerBuildRoleHelperTest.php @@ -0,0 +1,9 @@ +toContain('label="Use as a dedicated build server"') + ->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."'); +}); diff --git a/tests/Feature/ServerCreatePageLayoutTest.php b/tests/Feature/ServerCreatePageLayoutTest.php new file mode 100644 index 000000000..66d2c50f3 --- /dev/null +++ b/tests/Feature/ServerCreatePageLayoutTest.php @@ -0,0 +1,10 @@ +toContain('

New server

') + ->not->toContain('Back to servers') + ->not->toContain('title="Add a server"'); +}); diff --git a/tests/Feature/ServerCreationBuildRoleLayoutTest.php b/tests/Feature/ServerCreationBuildRoleLayoutTest.php new file mode 100644 index 000000000..2f5eaf1fa --- /dev/null +++ b/tests/Feature/ServerCreationBuildRoleLayoutTest.php @@ -0,0 +1,22 @@ +toContain('class="flex items-end gap-3"') + ->toContain('x-data="{ advancedOpen: false }"') + ->toContain('x-show="advancedOpen" x-cloak') + ->toContain('Advanced settings') + ->toContain('label="Use as a dedicated build server"') + ->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."'); +}); + +test('server creation places the IP address and private key before optional details', function () { + $view = file_get_contents(resource_path('views/livewire/server/new/by-ip.blade.php')); + + expect($view) + ->toContain('class="mb-5"') + ->and(strpos($view, 'id="ip"'))->toBeLessThan(strpos($view, 'id="private_key_id"')) + ->and(strpos($view, 'id="private_key_id"'))->toBeLessThan(strpos($view, 'id="name"')); +}); diff --git a/tests/Feature/ServerMetricsEmptyStateTest.php b/tests/Feature/ServerMetricsEmptyStateTest.php new file mode 100644 index 000000000..e3c34065b --- /dev/null +++ b/tests/Feature/ServerMetricsEmptyStateTest.php @@ -0,0 +1,10 @@ +after('@else')->before('@endif')->toString(); + + expect($sentinelRequiredState) + ->toContain('title="Sentinel is required"') + ->not->toContain('status="Unavailable"'); +}); diff --git a/tests/Feature/ServerValidationDialogTest.php b/tests/Feature/ServerValidationDialogTest.php new file mode 100644 index 000000000..bf6c139f3 --- /dev/null +++ b/tests/Feature/ServerValidationDialogTest.php @@ -0,0 +1,55 @@ +toContain('') + ->toContain(':isHighlighted="! $server->isFunctional()"') + ->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"'); +}); + +test('completed server validation shows a close action instead of empty logs', function () { + $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + + expect($view) + ->toContain('$validationComplete') + ->toContain('mt-auto') + ->toContain('') + ->toContain('@click="processDialogOpen = false"') + ->toContain('Validation complete') + ->toContain('Close'); +}); + +test('installation logs are only shown after an installation starts', function () { + $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + $component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php')); + + expect($view)->toContain('@elseif ($isInstalling)') + ->and($component) + ->toContain('public bool $isInstalling = false;') + ->toContain('$this->isInstalling = true;'); +}); + +test('server validation content scrolls within the dialog', function () { + $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + $activityMonitor = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php')); + + expect($view)->toContain('class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scrollbar"') + ->and($activityMonitor)->toContain("'overflow-hidden' => !\$fullHeight") + ->and($activityMonitor)->not->toContain("'h-full overflow-hidden' => !\$fullHeight"); +}); + +test('validation checkpoints use the standard bordered list treatment', function () { + $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + + expect($view) + ->toContain('data-validation-checkpoints') + ->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]'); +}); + +test('all validation checkpoints remain visible while only the current phase runs', function () { + $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + + expect($view)->not->toContain("@continue(! \$checkpoint['visible'])"); +}); diff --git a/tests/Unit/LogTimestampDisplayTest.php b/tests/Unit/LogTimestampDisplayTest.php new file mode 100644 index 000000000..d2846065e --- /dev/null +++ b/tests/Unit/LogTimestampDisplayTest.php @@ -0,0 +1,9 @@ +toContain("->format('Y-M-d H:i:s')") + ->not->toContain("->format('Y-M-d H:i:s.u')"); +}); diff --git a/tests/Unit/StartSentinelEndpointFallbackTest.php b/tests/Unit/StartSentinelEndpointFallbackTest.php new file mode 100644 index 000000000..7cb913682 --- /dev/null +++ b/tests/Unit/StartSentinelEndpointFallbackTest.php @@ -0,0 +1,16 @@ +toContain('ensureSentinelUrl()') + ->and($component) + ->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;') + ->and(file_get_contents(dirname(__DIR__, 2).'/app/Models/ServerSetting.php')) + ->toContain('generateSentinelUrl(ignoreEvent: true)') + ->toContain('Set an instance FQDN or public IP before enabling Sentinel.') + ->and($component) + ->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;'); +});