mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-13 19:09:50 +00:00
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.
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Destination;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\BaseModel;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneMariadb;
|
||||||
|
use App\Models\StandaloneMongodb;
|
||||||
|
use App\Models\StandaloneMysql;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Resources extends Component
|
||||||
|
{
|
||||||
|
#[Locked]
|
||||||
|
public $destination;
|
||||||
|
|
||||||
|
public array $resources = [];
|
||||||
|
|
||||||
|
public function mount(string $destination_uuid)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$destination = find_destination_for_current_team($destination_uuid);
|
||||||
|
if (! $destination) {
|
||||||
|
return redirect()->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<int, iterable<Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse>> $groups
|
||||||
|
* @return array<int, array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}>
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,6 @@ class Show extends Component
|
|||||||
#[Validate(['string', 'required'])]
|
#[Validate(['string', 'required'])]
|
||||||
public string $serverIp;
|
public string $serverIp;
|
||||||
|
|
||||||
public array $resources = [];
|
|
||||||
|
|
||||||
public function mount(string $destination_uuid)
|
public function mount(string $destination_uuid)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -35,71 +33,11 @@ class Show extends Component
|
|||||||
}
|
}
|
||||||
$this->destination = $destination;
|
$this->destination = $destination;
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
$this->loadResources();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
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)
|
public function syncData(bool $toModel = false)
|
||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use App\Jobs\ConnectProxyToNetworksJob;
|
use App\Jobs\ConnectProxyToNetworksJob;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class StandaloneDocker extends BaseModel
|
class StandaloneDocker extends BaseModel
|
||||||
@@ -127,7 +128,7 @@ class StandaloneDocker extends BaseModel
|
|||||||
return $this->morphMany(Service::class, 'destination');
|
return $this->morphMany(Service::class, 'destination');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function databases()
|
public function databases(): Collection
|
||||||
{
|
{
|
||||||
$postgresqls = $this->postgresqls;
|
$postgresqls = $this->postgresqls;
|
||||||
$redis = $this->redis;
|
$redis = $this->redis;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@if ($destination->getMorphClass() === 'App\\Models\\StandaloneDocker')
|
||||||
|
<div class="navbar-main">
|
||||||
|
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
|
||||||
|
<a class="{{ request()->routeIs('destination.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||||
|
href="{{ route('destination.show', ['destination_uuid' => $destination->uuid]) }}">
|
||||||
|
General
|
||||||
|
</a>
|
||||||
|
<a class="{{ request()->routeIs('destination.resources') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||||
|
href="{{ route('destination.resources', ['destination_uuid' => $destination->uuid]) }}">
|
||||||
|
Resources
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1>Destination</h1>
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">Resources deployed to this Docker network.</div>
|
||||||
|
|
||||||
|
@include('livewire.destination.navbar', ['destination' => $destination])
|
||||||
|
|
||||||
|
<div class="pt-4" x-data="{ search: '' }">
|
||||||
|
@if (count($resources) === 0)
|
||||||
|
<div class="py-4 text-sm opacity-70">No resources are using this destination.</div>
|
||||||
|
@else
|
||||||
|
<x-forms.input placeholder="Search resources..." x-model="search" id="null" />
|
||||||
|
<div class="overflow-x-auto pt-4">
|
||||||
|
<div class="inline-block min-w-full">
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Project</th>
|
||||||
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Environment</th>
|
||||||
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name</th>
|
||||||
|
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y">
|
||||||
|
@foreach ($resources as $row)
|
||||||
|
<tr class="dark:hover:bg-coolgray-300 hover:bg-neutral-100"
|
||||||
|
wire:key="destination-resource-{{ $row['type'] }}-{{ $row['uuid'] }}"
|
||||||
|
x-show="search === '' || '{{ addslashes($row['search']) }}'.includes(search.toLowerCase())">
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $row['project'] }}</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $row['environment'] }}</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||||
|
@if ($row['url'])
|
||||||
|
<a {{ wireNavigate() }} href="{{ $row['url'] }}">
|
||||||
|
{{ $row['name'] }}
|
||||||
|
<x-internal-link />
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span>{{ $row['name'] }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ ucfirst($row['type']) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
<x-deprecated-badge />
|
<x-deprecated-badge />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div class="flex gap-2">
|
@include('livewire.destination.navbar', ['destination' => $destination])
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-4">
|
||||||
<x-forms.input canGate="update" :canResource="$destination" id="name" label="Name" />
|
<x-forms.input canGate="update" :canResource="$destination" id="name" label="Name" />
|
||||||
<x-forms.input id="serverIp" label="Server IP" readonly />
|
<x-forms.input id="serverIp" label="Server IP" readonly />
|
||||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||||
@@ -28,24 +30,4 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
|
||||||
<div class="pt-6">
|
|
||||||
<h3>Resources</h3>
|
|
||||||
<div class="pb-2 text-xs opacity-70">Applications, services, and databases deployed to this network.</div>
|
|
||||||
@if (count($resources) === 0)
|
|
||||||
<div class="text-xs opacity-70 pt-2">No resources are using this destination.</div>
|
|
||||||
@else
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
|
|
||||||
@foreach ($resources as $row)
|
|
||||||
<a href="{{ $row['url'] }}"
|
|
||||||
class="relative flex flex-col dark:text-white coolbox group cursor-pointer">
|
|
||||||
<div class="box-title">{{ ucfirst($row['type']) }}: {{ $row['name'] }}</div>
|
|
||||||
<div class="box-description">{{ $row['project'] }} / {{ $row['environment'] }}</div>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Livewire\Admin\Index as AdminIndex;
|
|||||||
use App\Livewire\Boarding\Index as BoardingIndex;
|
use App\Livewire\Boarding\Index as BoardingIndex;
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
use App\Livewire\Destination\Index as DestinationIndex;
|
use App\Livewire\Destination\Index as DestinationIndex;
|
||||||
|
use App\Livewire\Destination\Resources as DestinationResources;
|
||||||
use App\Livewire\Destination\Show as DestinationShow;
|
use App\Livewire\Destination\Show as DestinationShow;
|
||||||
use App\Livewire\ForcePasswordReset;
|
use App\Livewire\ForcePasswordReset;
|
||||||
use App\Livewire\Notifications\Discord as NotificationDiscord;
|
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('/destinations', DestinationIndex::class)->name('destination.index');
|
||||||
Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show');
|
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', fn () => view('security.index'))->name('security.index');
|
||||||
Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index');
|
Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Destination\Resources as DestinationResources;
|
||||||
use App\Livewire\Destination\Show as DestinationShow;
|
use App\Livewire\Destination\Show as DestinationShow;
|
||||||
use App\Livewire\Project\New\DockerCompose;
|
use App\Livewire\Project\New\DockerCompose;
|
||||||
use App\Livewire\Project\New\DockerImage;
|
use App\Livewire\Project\New\DockerImage;
|
||||||
@@ -294,4 +295,80 @@ describe('Destination/Show team scope', function () {
|
|||||||
expect($component->get('destination'))->toBeNull();
|
expect($component->get('destination'))->toBeNull();
|
||||||
$component->assertRedirect(route('destination.index'));
|
$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=""');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user