fix(destinations): handle empty and server-scoped destinations

Build the global destinations list from actual destination records so empty
servers do not render duplicate empty states. Allow creating Docker destinations
for a selected team server outside the global usable list, authorize swarm
creation correctly, and store discovered swarm network names from the selected
network. Add feature coverage for empty states, selected-server mounting, and
swarm destination creation.
This commit is contained in:
Andras Bacsai
2026-05-19 12:50:08 +02:00
parent a67cc1d3a9
commit 65c0c92c02
6 changed files with 159 additions and 41 deletions
+8 -1
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Destination;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -11,9 +12,15 @@ class Index extends Component
#[Locked]
public $servers;
public function mount()
#[Locked]
public Collection $destinations;
public function mount(): void
{
$this->servers = Server::isUsable()->get();
$this->destinations = $this->servers
->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers))
->values();
}
public function render()
+18 -13
View File
@@ -33,44 +33,49 @@ class Docker extends Component
#[Validate(['required', 'boolean'])]
public bool $isSwarm = false;
public function mount(?string $server_id = null)
public function mount(?string $server_id = null): void
{
$this->network = new Cuid2;
$this->network = (string) new Cuid2;
$this->servers = Server::isUsable()->get();
if ($server_id) {
$foundServer = $this->servers->find($server_id) ?: $this->servers->first();
if (! $foundServer) {
throw new \Exception('Server not found.');
if (filled($server_id)) {
$this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail();
if (! $this->servers->contains('id', $this->selectedServer->id)) {
$this->servers->push($this->selectedServer);
}
$this->selectedServer = $foundServer;
$this->serverId = $this->selectedServer->id;
$this->serverId = (string) $this->selectedServer->id;
} else {
$foundServer = $this->servers->first();
if (! $foundServer) {
throw new \Exception('Server not found.');
}
$this->selectedServer = $foundServer;
$this->serverId = $this->selectedServer->id;
$this->serverId = (string) $this->selectedServer->id;
}
$this->generateName();
}
public function updatedServerId()
public function updatedServerId(): void
{
$this->selectedServer = $this->servers->find($this->serverId);
if (! $this->selectedServer) {
throw new \Exception('Server not found.');
}
$this->generateName();
}
public function generateName()
public function generateName(): void
{
$name = data_get($this->selectedServer, 'name', new Cuid2);
$this->name = str("{$name}-{$this->network}")->kebab();
}
public function submit()
public function submit(): mixed
{
try {
$this->authorize('create', StandaloneDocker::class);
$this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class);
$this->validate();
if ($this->isSwarm) {
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
+1 -1
View File
@@ -45,7 +45,7 @@ class Destinations extends Component
} else {
SwarmDocker::create([
'name' => $this->server->name.'-'.$name,
'network' => $this->name,
'network' => $name,
'server_id' => $this->server->id,
]);
}
@@ -14,34 +14,30 @@
</div>
<div class="subtitle">Network endpoints to deploy your resources.</div>
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($servers as $server)
@forelse ($server->destinations() as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
<a class="coolbox group" {{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">{{ $destination->name }}</div>
<div class="box-description">Server: {{ $destination->server->name }}</div>
@forelse ($destinations as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
<a class="coolbox group" {{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">{{ $destination->name }}</div>
<div class="box-description">Server: {{ $destination->server->name }}</div>
</div>
</a>
@endif
@if ($destination->getMorphClass() === 'App\Models\SwarmDocker')
<a class="coolbox group" {{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $destination->name }}
<x-deprecated-badge />
</div>
</a>
@endif
@if ($destination->getMorphClass() === 'App\Models\SwarmDocker')
<a class="coolbox group" {{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $destination->name }}
<x-deprecated-badge />
</div>
<div class="box-description">server: {{ $destination->server->name }}</div>
</div>
</a>
@endif
@empty
<div>No destinations found.</div>
@endforelse
<div class="box-description">server: {{ $destination->server->name }}</div>
</div>
</a>
@endif
@empty
<div>No servers found.</div>
<div>No destinations found.</div>
@endforelse
</div>
</div>
@@ -29,6 +29,9 @@
<x-forms.button>{{ data_get($docker, 'network') }} </x-forms.button>
</a>
@endforeach
@if ($server->standaloneDockers->isEmpty() && $server->swarmDockers->isEmpty())
<div class="text-sm text-neutral-500">No destinations configured for this server yet.</div>
@endif
</div>
@if ($networks->count() > 0)
<div class="pt-2">
@@ -0,0 +1,107 @@
<?php
use App\Livewire\Destination\New\Docker;
use App\Livewire\Server\Destinations;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('destination creation modal can mount with selected team server even when global usable server list excludes it', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
Livewire::test(Docker::class, ['server_id' => (string) $server->id])
->assertSet('selectedServer.id', $server->id)
->assertSet('serverId', (string) $server->id);
});
test('server destinations page renders when selected server has no destinations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
$this->get(route('server.destinations', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('Destinations')
->assertSee('No destinations configured for this server yet.')
->assertDontSee('Server not found.');
});
test('global destinations page does not render per-server empty states beside existing destinations', function () {
$serverWithDestination = Server::factory()->create(['team_id' => $this->team->id]);
$serverWithDestination->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$serverWithoutDestination = Server::factory()->create(['team_id' => $this->team->id]);
$serverWithoutDestination->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(fn () => $serverWithoutDestination->standaloneDockers()->delete());
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee($serverWithDestination->standaloneDockers()->first()->name)
->assertDontSee('No destinations found.');
});
test('global destinations page renders a single empty state when no usable servers have destinations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(fn () => $server->standaloneDockers()->delete());
$this->get(route('destination.index'))
->assertSuccessful()
->assertSee('No destinations found.');
});
test('adding a discovered swarm destination stores the selected network name', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_swarm_manager' => true,
]);
Livewire::test(Destinations::class, ['server_uuid' => $server->uuid])
->call('add', 'customer-network');
expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue();
});