mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(server): add Docker images page with per-image delete (#11689)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class DockerImages extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public array $images = [];
|
||||
|
||||
public ?array $usage = null;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public bool $loaded = false;
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->parameters = get_route_parameters();
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('server.index');
|
||||
}
|
||||
}
|
||||
|
||||
public function load(): void
|
||||
{
|
||||
try {
|
||||
if (! $this->server->isFunctional()) {
|
||||
return;
|
||||
}
|
||||
$containersByImage = $this->containersByImage();
|
||||
$this->images = format_docker_command_output_to_json(
|
||||
instant_remote_process(["docker image ls --no-trunc --format '{{json .}}'"], $this->server, false)
|
||||
)
|
||||
->map(fn (array $image) => $this->summarize($image, $containersByImage))
|
||||
->values()
|
||||
->all();
|
||||
$this->usage = $this->diskUsage();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
} finally {
|
||||
$this->loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $reference, string $password = ''): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$image = collect($this->images)->firstWhere('reference', $reference);
|
||||
if (! $image) {
|
||||
throw new \RuntimeException('Unknown image, refresh the page and try again.');
|
||||
}
|
||||
if ($image['containers'] !== []) {
|
||||
throw new \RuntimeException('This image is used by '.implode(', ', $image['containers']).'. Remove those containers first.');
|
||||
}
|
||||
instant_remote_process(['docker image rm '.escapeshellarg($reference)], $this->server);
|
||||
auditLog('ui.server.docker_image_deleted', [
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
'image' => $reference,
|
||||
]);
|
||||
$this->dispatch('success', "Deleted {$reference}.");
|
||||
$this->load();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map every image ID to the containers using it. Containers are inspected instead of
|
||||
* read from `docker ps --format {{.Image}}`, because that column shows whatever
|
||||
* reference the container was started with, which may be a tag that has since moved.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function containersByImage(): array
|
||||
{
|
||||
$output = instant_remote_process(
|
||||
["docker ps -a --no-trunc --format '{{.ID}}' | xargs -r docker container inspect --format '{{.Image}}#{{.Name}}' 2>/dev/null || true"],
|
||||
$this->server,
|
||||
false
|
||||
);
|
||||
|
||||
return str($output ?? '')->explode("\n")
|
||||
->map(fn ($line) => explode('#', trim($line), 2))
|
||||
->filter(fn ($parts) => count($parts) === 2 && $parts[0] !== '')
|
||||
->groupBy(fn ($parts) => $parts[0])
|
||||
->map(fn ($rows) => $rows->map(fn ($parts) => ltrim($parts[1], '/'))->sort()->values()->all())
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total and reclaimable image size as reported by Docker itself, so shared layers
|
||||
* are not counted twice the way summing the per-image sizes would.
|
||||
*/
|
||||
private function diskUsage(): ?array
|
||||
{
|
||||
$row = format_docker_command_output_to_json(
|
||||
instant_remote_process(["docker system df --format '{{json .}}'"], $this->server, false)
|
||||
)->firstWhere('Type', 'Images');
|
||||
|
||||
if (! $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'size' => data_get($row, 'Size'),
|
||||
'reclaimable' => data_get($row, 'Reclaimable'),
|
||||
];
|
||||
}
|
||||
|
||||
private function summarize(array $image, array $containersByImage): array
|
||||
{
|
||||
$repository = (string) data_get($image, 'Repository');
|
||||
$tag = (string) data_get($image, 'Tag');
|
||||
$id = (string) data_get($image, 'ID');
|
||||
$isDangling = $repository === '<none>' || $tag === '<none>';
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'short_id' => str($id)->after('sha256:')->limit(12, '')->value(),
|
||||
'repository' => $repository,
|
||||
'tag' => $tag,
|
||||
'dangling' => $isDangling,
|
||||
// Remove the tag when there is one: an image ID with several tags cannot be
|
||||
// removed by ID, Docker refuses with "image is referenced in multiple repositories".
|
||||
'reference' => $isDangling ? $id : "{$repository}:{$tag}",
|
||||
'size' => data_get($image, 'Size'),
|
||||
'created' => data_get($image, 'CreatedSince'),
|
||||
'containers' => $containersByImage[$id] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$search = trim($this->search);
|
||||
$images = collect($this->images);
|
||||
if ($search !== '') {
|
||||
$images = $images->filter(fn (array $image) => str($image['repository'].':'.$image['tag'].' '.$image['short_id'])
|
||||
->contains($search, ignoreCase: true));
|
||||
}
|
||||
|
||||
return view('livewire.server.docker-images', [
|
||||
// Not "images": Livewire shares public properties with the view, so that name is
|
||||
// already taken by the unfiltered array.
|
||||
'visibleImages' => $images->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3152,6 +3152,11 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
grid-template-columns: minmax(9rem, 1fr) minmax(12rem, 1.5fr) 7rem minmax(9rem, auto);
|
||||
}
|
||||
|
||||
.server-images-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.6fr) minmax(5rem, 0.8fr) 7.5rem 6.5rem 4.5rem minmax(8rem, 1fr) 6.5rem;
|
||||
min-width: 55rem;
|
||||
}
|
||||
|
||||
.package-updates-table-grid {
|
||||
grid-template-columns: minmax(12rem, 1.4fr) minmax(10rem, 1fr) 6rem;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,14 @@
|
||||
'group' => 'Networking',
|
||||
'visible' => $server->team->usesSwarm() && ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel,
|
||||
],
|
||||
[
|
||||
'label' => 'Images',
|
||||
'route' => 'server.docker-images',
|
||||
'active' => $activeMenu === 'docker-images',
|
||||
'icon' => 'layers',
|
||||
'group' => 'Operations',
|
||||
'visible' => $server->isFunctional(),
|
||||
],
|
||||
[
|
||||
'label' => 'Docker Cleanup',
|
||||
'route' => 'server.docker-cleanup',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<div wire:init="load">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > Images | Coolify
|
||||
</x-slot>
|
||||
|
||||
<livewire:server.navbar :server="$server" />
|
||||
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="docker-images" />
|
||||
|
||||
<div class="application-settings-form w-full min-w-0">
|
||||
<x-application.settings-section id="server-docker-images-section" title="Images"
|
||||
helper="Docker images stored on this server. Images that no container uses can be deleted here."
|
||||
flush>
|
||||
<x-slot:actions>
|
||||
@if ($usage)
|
||||
<x-status-badge :status="'Total ' . $usage['size']" type="neutral" />
|
||||
<x-status-badge :status="'Reclaimable ' . $usage['reclaimable']" type="warning" />
|
||||
@endif
|
||||
<x-forms.button wire:click="load">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="relative w-full max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input wire:model.live.debounce.300ms="search" type="search"
|
||||
placeholder="Search images by repository, tag or ID" aria-label="Search images"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!$loaded)
|
||||
<div class="p-6">
|
||||
<x-loading text="Reading images from the Docker daemon" />
|
||||
</div>
|
||||
@elseif ($visibleImages->isEmpty())
|
||||
<div class="p-6">
|
||||
<x-empty size="sm" :title="trim($search) !== '' ? 'No matching images' : 'No images found'" :description="trim($search) !== ''
|
||||
? 'Try another repository, tag or ID, or clear the search.'
|
||||
: 'Images pulled or built on this server will appear here.'" icon-name="layers" />
|
||||
</div>
|
||||
@else
|
||||
<div class="data-table">
|
||||
<div class="data-table-header server-images-table-grid">
|
||||
<span>Repository</span>
|
||||
<span>Tag</span>
|
||||
<span>Image ID</span>
|
||||
<span>Created</span>
|
||||
<span>Size</span>
|
||||
<span>Used by</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
@foreach ($visibleImages as $image)
|
||||
<div wire:key="image-{{ $image['reference'] }}"
|
||||
class="data-table-row server-images-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
|
||||
<div class="min-w-0 truncate text-[12px] font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $image['repository'] }}">
|
||||
{{ $image['repository'] }}
|
||||
</div>
|
||||
<div class="min-w-0 truncate text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
{{ $image['tag'] }}
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span class="truncate font-mono text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
{{ $image['short_id'] }}
|
||||
</span>
|
||||
<x-copy-button :value="$image['id']" label="Copy image ID" />
|
||||
</div>
|
||||
<div class="truncate text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
{{ $image['created'] }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">{{ $image['size'] }}</div>
|
||||
<div class="min-w-0">
|
||||
@if ($image['containers'] === [])
|
||||
<x-status-badge status="Unused" type="warning" />
|
||||
@else
|
||||
<span class="block truncate text-[11px] text-neutral-600 dark:text-fg-dim"
|
||||
title="{{ implode(', ', $image['containers']) }}">
|
||||
{{ implode(', ', $image['containers']) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
@if ($image['containers'] === [])
|
||||
@can('update', $server)
|
||||
<x-modal-confirmation title="Delete Docker Image?" buttonTitle="Delete"
|
||||
submitAction="delete({{ $image['reference'] }})" :actions="[
|
||||
'Delete ' . $image['reference'] . ' from this server.',
|
||||
'It has to be pulled or built again before it can be used.',
|
||||
]"
|
||||
confirmationText="{{ $image['reference'] }}"
|
||||
confirmationLabel="Confirm by entering the image reference"
|
||||
shortConfirmationLabel="Image" step2ButtonText="Delete Image"
|
||||
isErrorButton :confirmWithPassword="false" />
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +64,7 @@ use App\Livewire\Server\CreatePage as ServerCreatePage;
|
||||
use App\Livewire\Server\Delete as DeleteServer;
|
||||
use App\Livewire\Server\Destinations as ServerDestinations;
|
||||
use App\Livewire\Server\DockerCleanup;
|
||||
use App\Livewire\Server\DockerImages;
|
||||
use App\Livewire\Server\Index as ServerIndex;
|
||||
use App\Livewire\Server\LogDrains;
|
||||
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
|
||||
@@ -396,6 +397,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
|
||||
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
|
||||
Route::get('/images', DockerImages::class)->name('server.docker-images');
|
||||
Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource');
|
||||
Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource');
|
||||
Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource');
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\DockerImages;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
$this->withoutVite();
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], []));
|
||||
});
|
||||
|
||||
$nginxId = 'sha256:'.str_repeat('a', 64);
|
||||
$danglingId = 'sha256:'.str_repeat('b', 64);
|
||||
|
||||
$fakeDocker = function (string $nginxId, string $danglingId) {
|
||||
Process::fake([
|
||||
'*docker image ls*' => Process::result(output: implode("\n", [
|
||||
json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => $nginxId, 'Size' => '52.5MB', 'CreatedSince' => '3 weeks ago']),
|
||||
json_encode(['Repository' => '<none>', 'Tag' => '<none>', 'ID' => $danglingId, 'Size' => '11MB', 'CreatedSince' => '2 days ago']),
|
||||
])),
|
||||
'*docker ps -a*' => Process::result(output: "{$nginxId}#/web\n"),
|
||||
'*docker system df*' => Process::result(output: json_encode([
|
||||
'Type' => 'Images', 'TotalCount' => '2', 'Active' => '1', 'Size' => '63.5MB', 'Reclaimable' => '11MB (17%)',
|
||||
])),
|
||||
'*docker image rm*' => Process::result(output: 'Deleted: '.$danglingId),
|
||||
]);
|
||||
};
|
||||
|
||||
it('renders the images page for a server', function () use ($fakeDocker, $nginxId, $danglingId) {
|
||||
$fakeDocker($nginxId, $danglingId);
|
||||
|
||||
$this->get(route('server.docker-images', ['server_uuid' => $this->server->uuid]))
|
||||
->assertOk()
|
||||
->assertSee('Images');
|
||||
});
|
||||
|
||||
it('lists images and marks the ones no container uses', function () use ($fakeDocker, $nginxId, $danglingId) {
|
||||
$fakeDocker($nginxId, $danglingId);
|
||||
|
||||
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('load')
|
||||
->assertSet('loaded', true)
|
||||
->assertSet('images.0.repository', 'nginx')
|
||||
->assertSet('images.0.reference', 'nginx:1.27')
|
||||
->assertSet('images.0.short_id', str_repeat('a', 12))
|
||||
->assertSet('images.0.size', '52.5MB')
|
||||
->assertSet('images.0.containers', ['web'])
|
||||
->assertSet('images.1.reference', $danglingId)
|
||||
->assertSet('images.1.dangling', true)
|
||||
->assertSet('images.1.containers', [])
|
||||
->assertSet('usage.size', '63.5MB')
|
||||
->assertSet('usage.reclaimable', '11MB (17%)')
|
||||
->assertSee('Unused')
|
||||
->assertSee('web');
|
||||
});
|
||||
|
||||
it('deletes an unused image', function () use ($fakeDocker, $nginxId, $danglingId) {
|
||||
$fakeDocker($nginxId, $danglingId);
|
||||
|
||||
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('load')
|
||||
->call('delete', $danglingId)
|
||||
->assertDispatched('success');
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "docker image rm '{$danglingId}'"));
|
||||
});
|
||||
|
||||
it('refuses to delete an image that a container is using', function () use ($fakeDocker, $nginxId, $danglingId) {
|
||||
$fakeDocker($nginxId, $danglingId);
|
||||
|
||||
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('load')
|
||||
->call('delete', 'nginx:1.27')
|
||||
->assertDispatched('error');
|
||||
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'docker image rm'));
|
||||
});
|
||||
|
||||
it('filters images by the search term', function () use ($fakeDocker, $nginxId, $danglingId) {
|
||||
$fakeDocker($nginxId, $danglingId);
|
||||
|
||||
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('load')
|
||||
->set('search', 'nginx')
|
||||
->assertSee('1.27')
|
||||
->assertDontSee(str_repeat('b', 12));
|
||||
});
|
||||
Reference in New Issue
Block a user