From 13e94a499d9c5d88834e9ad59fb2e566e369ad02 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:24:36 +0200 Subject: [PATCH] feat(destinations): split Docker resources into separate page Move standalone Docker destination resource listings out of the general settings view and add a searchable resources page with team-scoped tests. --- app/Livewire/Destination/Resources.php | 125 ++++++++++++++++++ app/Livewire/Destination/Show.php | 62 --------- app/Models/StandaloneDocker.php | 3 +- .../livewire/destination/navbar.blade.php | 14 ++ .../livewire/destination/resources.blade.php | 53 ++++++++ .../views/livewire/destination/show.blade.php | 24 +--- routes/web.php | 2 + tests/Feature/TeamScopedDestinationTest.php | 77 +++++++++++ 8 files changed, 276 insertions(+), 84 deletions(-) create mode 100644 app/Livewire/Destination/Resources.php create mode 100644 resources/views/livewire/destination/navbar.blade.php create mode 100644 resources/views/livewire/destination/resources.blade.php diff --git a/app/Livewire/Destination/Resources.php b/app/Livewire/Destination/Resources.php new file mode 100644 index 000000000..c71010411 --- /dev/null +++ b/app/Livewire/Destination/Resources.php @@ -0,0 +1,125 @@ +route('destination.index'); + } + if (! $destination instanceof StandaloneDocker) { + return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]); + } + + $this->destination = $destination; + $this->loadResources(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * Load applications, services, and database resources deployed to the standalone Docker destination. + * + * @return void Populates the resources property for display. + */ + public function loadResources(): void + { + $this->resources = $this->collectResources([ + $this->destination->applications, + $this->destination->services, + $this->destination->postgresqls, + $this->destination->redis, + $this->destination->mongodbs, + $this->destination->mysqls, + $this->destination->mariadbs, + $this->destination->keydbs, + $this->destination->dragonflies, + $this->destination->clickhouses, + ]); + } + + /** + * @param array> $groups + * @return array + */ + protected function collectResources(array $groups): array + { + $rows = []; + foreach ($groups as $group) { + foreach ($group as $resource) { + $rows[] = $this->resourceRow($resource); + } + } + + return $rows; + } + + /** + * @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource + * @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string} + */ + protected function resourceRow(BaseModel $resource): array + { + $type = match (true) { + $resource instanceof Application => 'application', + $resource instanceof Service => 'service', + default => 'database', + }; + $environment = $resource->environment; + $project = $environment?->project; + $routeName = "project.{$type}.configuration"; + $url = ($project && $environment) + ? route($routeName, [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + "{$type}_uuid" => $resource->uuid, + ]) + : null; + + return [ + 'uuid' => $resource->uuid, + 'type' => $type, + 'name' => $resource->name, + 'project' => $project?->name, + 'environment' => $environment?->name, + 'url' => $url, + 'search' => strtolower(implode(' ', array_filter([ + $type, + $resource->name, + $project?->name, + $environment?->name, + ]))), + ]; + } + + public function render(): View + { + return view('livewire.destination.resources'); + } +} diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 11afbbccc..9d55d7462 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -24,8 +24,6 @@ class Show extends Component #[Validate(['string', 'required'])] public string $serverIp; - public array $resources = []; - public function mount(string $destination_uuid) { try { @@ -35,71 +33,11 @@ class Show extends Component } $this->destination = $destination; $this->syncData(); - $this->loadResources(); } catch (\Throwable $e) { return handleError($e, $this); } } - public function loadResources(): void - { - if ($this->destination->getMorphClass() !== \App\Models\StandaloneDocker::class) { - return; - } - - $this->resources = $this->collectResources([ - $this->destination->applications, - $this->destination->services, - $this->destination->postgresqls, - $this->destination->redis, - $this->destination->mongodbs, - $this->destination->mysqls, - $this->destination->mariadbs, - $this->destination->keydbs, - $this->destination->dragonflies, - $this->destination->clickhouses, - ]); - } - - protected function collectResources(array $groups): array - { - $rows = []; - foreach ($groups as $group) { - foreach ($group as $resource) { - $rows[] = $this->resourceRow($resource); - } - } - - return $rows; - } - - protected function resourceRow($resource): array - { - $type = match (true) { - $resource instanceof \App\Models\Application => 'application', - $resource instanceof \App\Models\Service => 'service', - default => 'database', - }; - $environment = $resource->environment; - $project = $environment?->project; - $routeName = "project.{$type}.configuration"; - $url = ($project && $environment) - ? route($routeName, [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - "{$type}_uuid" => $resource->uuid, - ]) - : null; - - return [ - 'type' => $type, - 'name' => $resource->name, - 'project' => $project?->name, - 'environment' => $environment?->name, - 'url' => $url, - ]; - } - public function syncData(bool $toModel = false) { if ($toModel) { diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index d12a15a7c..1c5cfd342 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Jobs\ConnectProxyToNetworksJob; use App\Support\ValidationPatterns; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; class StandaloneDocker extends BaseModel @@ -127,7 +128,7 @@ class StandaloneDocker extends BaseModel return $this->morphMany(Service::class, 'destination'); } - public function databases() + public function databases(): Collection { $postgresqls = $this->postgresqls; $redis = $this->redis; diff --git a/resources/views/livewire/destination/navbar.blade.php b/resources/views/livewire/destination/navbar.blade.php new file mode 100644 index 000000000..4585e57a9 --- /dev/null +++ b/resources/views/livewire/destination/navbar.blade.php @@ -0,0 +1,14 @@ +@if ($destination->getMorphClass() === 'App\\Models\\StandaloneDocker') + +@endif diff --git a/resources/views/livewire/destination/resources.blade.php b/resources/views/livewire/destination/resources.blade.php new file mode 100644 index 000000000..8883cc021 --- /dev/null +++ b/resources/views/livewire/destination/resources.blade.php @@ -0,0 +1,53 @@ +
+
+

Destination

+
+
Resources deployed to this Docker network.
+ + @include('livewire.destination.navbar', ['destination' => $destination]) + +
+ @if (count($resources) === 0) +
No resources are using this destination.
+ @else + +
+
+
+ + + + + + + + + + + @foreach ($resources as $row) + + + + + + + @endforeach + +
ProjectEnvironmentNameType
{{ $row['project'] }}{{ $row['environment'] }} + @if ($row['url']) + + {{ $row['name'] }} + + + @else + {{ $row['name'] }} + @endif + {{ ucfirst($row['type']) }}
+
+
+
+ @endif +
+
diff --git a/resources/views/livewire/destination/show.blade.php b/resources/views/livewire/destination/show.blade.php index 1bb179823..77b7209b7 100644 --- a/resources/views/livewire/destination/show.blade.php +++ b/resources/views/livewire/destination/show.blade.php @@ -20,7 +20,9 @@ @endif -
+ @include('livewire.destination.navbar', ['destination' => $destination]) + +
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') @@ -28,24 +30,4 @@ @endif
- - @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') -
-

Resources

-
Applications, services, and databases deployed to this network.
- @if (count($resources) === 0) -
No resources are using this destination.
- @else - - @endif -
- @endif
diff --git a/routes/web.php b/routes/web.php index 3dab7b4fe..f77277699 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Boarding\Index as BoardingIndex; use App\Livewire\Dashboard; use App\Livewire\Destination\Index as DestinationIndex; +use App\Livewire\Destination\Resources as DestinationResources; use App\Livewire\Destination\Show as DestinationShow; use App\Livewire\ForcePasswordReset; use App\Livewire\Notifications\Discord as NotificationDiscord; @@ -304,6 +305,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/destinations', DestinationIndex::class)->name('destination.index'); Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); + Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); // Route::get('/security', fn () => view('security.index'))->name('security.index'); Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php index bdac0251d..943676ba4 100644 --- a/tests/Feature/TeamScopedDestinationTest.php +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -1,5 +1,6 @@ get('destination'))->toBeNull(); $component->assertRedirect(route('destination.index')); }); + + test('general page links to separate resources page without rendering the resources table', function () { + Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]) + ->assertSee('General') + ->assertSee('Resources') + ->assertDontSee('Search resources...') + ->assertDontSee('No resources are using this destination.'); + }); + + test('mount with own standalone destination lists deployed resources', function () { + Application::factory()->create([ + 'name' => 'application-on-destination', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ]); + Service::factory()->create([ + 'name' => 'service-on-destination', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ]); + StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::create([ + 'uuid' => fake()->uuid(), + 'name' => 'database-on-destination', + 'postgres_password' => 'password', + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ])); + + Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid]) + ->assertSee('Search resources...') + ->assertSee('Project') + ->assertSee('Environment') + ->assertSee('Name') + ->assertSee('Type') + ->assertSee('application-on-destination') + ->assertSee('service-on-destination') + ->assertSee('database-on-destination') + ->assertSee($this->projectA->name) + ->assertSee($this->environmentA->name); + }); + + test('mount with own standalone destination shows empty state without resources', function () { + Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid]) + ->assertSee('No resources are using this destination.'); + }); + + test('mount with own standalone destination does not list another team resources', function () { + Application::factory()->create([ + 'name' => 'other-team-application', + 'environment_id' => $this->environmentB->id, + 'destination_id' => $this->destinationB->id, + 'destination_type' => StandaloneDocker::class, + ]); + + Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid]) + ->assertDontSee('other-team-application'); + }); + + test('resource without project renders as non-clickable row', function () { + StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::create([ + 'uuid' => fake()->uuid(), + 'name' => 'database-without-project', + 'postgres_password' => 'password', + 'environment_id' => null, + 'destination_id' => $this->destinationA->id, + 'destination_type' => StandaloneDocker::class, + ])); + + $component = Livewire::test(DestinationResources::class, ['destination_uuid' => $this->destinationA->uuid]) + ->assertSee('database-without-project'); + + expect($component->html())->not->toContain('href=""'); + }); });