feat(servers): separate connection and provisioning options

Group existing server connections separately from cloud provisioning and use a select for dedicated build server roles.
This commit is contained in:
Andras Bacsai committed 2026-08-10 10:09:03 +02:00
1 parent faf527d554
commit 4ba6aa3500
5 files changed
+148 -105

No files matched your search

@@ -95,7 +95,11 @@
Select where to deploy your applications and databases. You can add more servers later.
</x-slot:question>
<x-slot:actions>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
<div class="w-full space-y-6">
<section>
<h3 class="text-base font-semibold">Add a server</h3>
<p class="mb-3 text-sm text-neutral-500 dark:text-neutral-400">Use this machine or connect a server you already manage.</p>
<div class="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
<button
class="group relative cursor-pointer min-h-36 rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
wire:target="setServerType('localhost')" wire:click="setServerType('localhost')">
@@ -134,26 +138,29 @@
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
<div>
<h3 class="mb-1 text-[14px] font-semibold">Remote server</h3>
<h3 class="mb-1 text-[14px] font-semibold">IP address or domain</h3>
<p class="text-sm dark:text-neutral-400">
Connect via SSH to any server: cloud VPS, bare metal, or home infrastructure.
Connect via SSH using a server IP address or domain.
</p>
</div>
</div>
</button>
</div>
</section>
@can('viewAny', App\Models\CloudProviderToken::class)
<section>
<h3 class="text-base font-semibold">Provision a server</h3>
<p class="mb-3 text-sm text-neutral-500 dark:text-neutral-400">Create a server with a cloud provider.</p>
<div class="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
@if ($currentState === 'select-server-type')
<x-modal-input title="Connect a Hetzner Server" isFullWidth>
<x-slot:content>
<div
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex h-full flex-col gap-4 text-left">
<svg class="size-10 shrink-0" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
fill="white" />
</svg>
<img src="{{ asset('svgs/hetzner.svg') }}" alt="Hetzner"
class="size-10 shrink-0">
<div class="min-h-0 flex-1">
<h3 class="mb-1 text-[14px] font-semibold">Hetzner Cloud</h3>
<p class="text-sm dark:text-neutral-400">
@@ -170,12 +177,8 @@
<div
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex h-full flex-col gap-4 text-left">
<svg class="size-10 shrink-0" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z"
fill="white" />
</svg>
<img src="https://www.vultr.com/media/logo_ondark.svg" alt="Vultr"
class="h-10 w-28 shrink-0 object-contain object-left">
<div class="min-h-0 flex-1">
<h3 class="mb-1 text-[14px] font-semibold">Vultr Cloud</h3>
<p class="text-sm dark:text-neutral-400">
@@ -205,6 +208,8 @@
<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
</div>
</section>
@endcan
</div>
@@ -421,19 +426,13 @@
<x-forms.input placeholder="Optional: Note what this server hosts" label="Description"
id="remoteServerDescription" wire:model="remoteServerDescription" />
<x-forms.collapsible title="Advanced Connection Settings"
<x-forms.collapsible title="Advanced Settings"
content-class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
id="remoteServerPort" wire:model="remoteServerPort" />
<div>
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
wire:model="remoteServerUser" />
<p class="mt-1 text-xs text-black dark:text-white">
Non-root user support is experimental.
<a class="font-bold underline hover:text-coollabs" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">Learn
more</a>
</p>
</div>
</x-forms.collapsible>
<x-forms.button type="submit" class="w-full lg:w-auto">Validate Connection</x-forms.button>
@@ -15,91 +15,89 @@
</div>
@if (!$selectedType)
<div class="application-settings-form">
<div class="application-settings-form flex flex-col gap-6">
<section class="application-settings-section">
<div class="application-settings-section-body is-flush">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start justify-between gap-3">
<span
class="flex size-8 items-center justify-center rounded-lg bg-[#D50C2D] text-[12px] font-bold text-white">H</span>
<span
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
Provider
</span>
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Hetzner</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Provision from Hetzner Cloud.
</p>
</div>
</a>
<a href="{{ route('server.create.type', ['type' => 'vultr']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start justify-between gap-3">
<span
class="flex size-8 items-center justify-center rounded-lg bg-[#007BFC] text-[12px] font-bold text-white">V</span>
<span
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
Provider
</span>
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Vultr</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Provision from Vultr Cloud.
</p>
</div>
</a>
<a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start justify-between gap-3">
<x-digital-ocean-icon class="size-8" />
<span
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
Provider
</span>
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">DigitalOcean</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Provision a new Droplet.
</p>
</div>
</a>
@endcan
<a href="{{ route('server.create.type', ['type' => 'manual']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start justify-between gap-3">
<span
class="flex size-8 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
</span>
<span
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
Manual
</span>
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">IP address</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Connect an existing server over SSH.
</p>
</div>
</a>
<div class="application-settings-section-header">
<h2 class="application-settings-section-title">Add a server</h2>
<p class="application-settings-section-description">Connect a server you already manage.</p>
</div>
<div class="application-settings-section-body is-flush">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
<a href="{{ route('server.create.type', ['type' => 'manual']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start">
<span
class="flex size-8 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
</span>
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">IP address or domain</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Connect an existing server over SSH.
</p>
</div>
</a>
</div>
</div>
</section>
@can('viewAny', App\Models\CloudProviderToken::class)
<section class="application-settings-section">
<div class="application-settings-section-header">
<h2 class="application-settings-section-title">Provision a server</h2>
<p class="application-settings-section-description">Create a server with a cloud provider.</p>
</div>
<div class="application-settings-section-body is-flush">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3">
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start">
<img src="{{ asset('svgs/hetzner.svg') }}" alt="Hetzner" class="size-8">
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Hetzner</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Provision from Hetzner Cloud.
</p>
</div>
</a>
<a href="{{ route('server.create.type', ['type' => 'vultr']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start">
<img src="https://www.vultr.com/media/logo_ondark.svg" alt="Vultr"
class="h-8 w-20 object-contain object-left">
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Vultr</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Provision from Vultr Cloud.
</p>
</div>
</a>
<a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}>
<div class="flex items-start">
<x-digital-ocean-icon class="size-8" />
</div>
<div class="mt-auto pt-5">
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">DigitalOcean</h3>
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
Provision a new Droplet.
</p>
</div>
</a>
</div>
</div>
</section>
@endcan
</div>
@else
<div class="application-settings-form">
@@ -83,9 +83,12 @@
helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required />
</div>
<x-forms.checkbox id="is_build_server"
<x-forms.select id="is_build_server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
label="Use as a dedicated build server" />
label="Use as a dedicated build server">
<option value="0">No</option>
<option value="1">Yes</option>
</x-forms.select>
</x-forms.collapsible>
</x-application.settings-section>
</form>
@@ -18,3 +18,42 @@ test('provider pages only show the token action in the account panel', function
->not->toContain('new-server-token-')
->not->toContain('tokenProviderName');
});
test('server selection separates existing servers from cloud provisioning', function (string $viewPath) {
$view = file_get_contents(resource_path($viewPath));
expect($view)
->toContain('Add a server')
->toContain('IP address or domain')
->toContain('Provision a server')
->and(strpos($view, 'Add a server'))->toBeLessThan(strpos($view, 'Provision a server'));
})->with([
'new server page' => 'views/livewire/server/create.blade.php',
'onboarding' => 'views/livewire/boarding/index.blade.php',
]);
test('new server sections have vertical spacing', function () {
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
expect($view)->toContain('<div class="application-settings-form flex flex-col gap-6">');
});
test('server selection uses the provider logos', function () {
$newServerView = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
$onboardingView = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($newServerView)
->toContain('src="https://www.vultr.com/media/logo_ondark.svg"')
->toContain("src=\"{{ asset('svgs/hetzner.svg') }}\"")
->and($onboardingView)
->toContain('src="https://www.vultr.com/media/logo_ondark.svg"')
->toContain("src=\"{{ asset('svgs/hetzner.svg') }}\"");
});
test('new server cards do not show method badges', function () {
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
expect($view)
->not->toContain("\n Manual\n")
->not->toContain("\n Provider\n");
});
@@ -6,7 +6,11 @@ test('server creation keeps private key actions together and advanced options co
expect($view)
->toContain('class="flex items-end gap-3"')
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
->toContain('<x-forms.select id="is_build_server"')
->toContain('label="Use as a dedicated build server"')
->toContain('<option value="0">No</option>')
->toContain('<option value="1">Yes</option>')
->not->toContain('<x-forms.checkbox id="is_build_server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
});