From 2e2d1d321c0e38cc3fd13cb8ad107089adcab376 Mon Sep 17 00:00:00 2001 From: badnikhil Date: Tue, 8 Sep 2026 15:21:01 +0530 Subject: [PATCH 1/2] feat(server): add Docker images page with per-image delete Adds an Images entry to the server sidebar that lists the Docker images stored on that server: repository, tag, image ID, age, size and the containers using each one. Images no container references are labelled Unused and can be deleted individually; Docker's own reported total and reclaimable size are shown above the table. --- app/Livewire/Server/DockerImages.php | 161 ++++++++++++++++++ resources/css/app.css | 5 + .../views/components/server/sidebar.blade.php | 8 + .../livewire/server/docker-images.blade.php | 105 ++++++++++++ routes/web.php | 2 + tests/Feature/ServerDockerImagesTest.php | 103 +++++++++++ 6 files changed, 384 insertions(+) create mode 100644 app/Livewire/Server/DockerImages.php create mode 100644 resources/views/livewire/server/docker-images.blade.php create mode 100644 tests/Feature/ServerDockerImagesTest.php diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php new file mode 100644 index 0000000000..79669089d9 --- /dev/null +++ b/app/Livewire/Server/DockerImages.php @@ -0,0 +1,161 @@ +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): 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> + */ + 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 === '' || $tag === ''; + + 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(), + ]); + } +} diff --git a/resources/css/app.css b/resources/css/app.css index 746162b112..8fe70dd276 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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; } diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 3645dd2a27..23352ab5a1 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -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', diff --git a/resources/views/livewire/server/docker-images.blade.php b/resources/views/livewire/server/docker-images.blade.php new file mode 100644 index 0000000000..41279e26a2 --- /dev/null +++ b/resources/views/livewire/server/docker-images.blade.php @@ -0,0 +1,105 @@ +
+ + {{ data_get_str($server, 'name')->limit(10) }} > Images | Coolify + + + + +
+ + +
+ + + @if ($usage) + + + @endif + + + Refresh + + + +
+
+ + +
+
+ + @if (!$loaded) +
+ +
+ @elseif ($visibleImages->isEmpty()) +
+ +
+ @else +
+
+ Repository + Tag + Image ID + Created + Size + Used by + Actions +
+ @foreach ($visibleImages as $image) +
+
+ {{ $image['repository'] }} +
+
+ {{ $image['tag'] }} +
+
+ + {{ $image['short_id'] }} + + +
+
+ {{ $image['created'] }} +
+
{{ $image['size'] }}
+
+ @if ($image['containers'] === []) + + @else + + {{ implode(', ', $image['containers']) }} + + @endif +
+
+ @if ($image['containers'] === []) + + Delete + + @endif +
+
+ @endforeach +
+ @endif +
+
+
+
diff --git a/routes/web.php b/routes/web.php index a3ca3630f1..f1e125f683 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/ServerDockerImagesTest.php b/tests/Feature/ServerDockerImagesTest.php new file mode 100644 index 0000000000..51f139930b --- /dev/null +++ b/tests/Feature/ServerDockerImagesTest.php @@ -0,0 +1,103 @@ +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' => '', 'Tag' => '', '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)); +}); From 0fec411b723edaf4ae68c6bb291bc00ed090c78a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:20:46 +0200 Subject: [PATCH 2/2] feat(server): confirm Docker image deletion with a typed reference modal Replace the browser confirm on the Delete button with a two-step confirmation modal. To delete, users now type the image reference. The modal is shown only to users who can update the server. delete() now accepts an optional password argument so it works with the modal's submit action. --- app/Livewire/Server/DockerImages.php | 2 +- .../livewire/server/docker-images.blade.php | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php index 79669089d9..dab873c650 100644 --- a/app/Livewire/Server/DockerImages.php +++ b/app/Livewire/Server/DockerImages.php @@ -53,7 +53,7 @@ class DockerImages extends Component } } - public function delete(string $reference): void + public function delete(string $reference, string $password = ''): void { try { $this->authorize('update', $this->server); diff --git a/resources/views/livewire/server/docker-images.blade.php b/resources/views/livewire/server/docker-images.blade.php index 41279e26a2..948783902f 100644 --- a/resources/views/livewire/server/docker-images.blade.php +++ b/resources/views/livewire/server/docker-images.blade.php @@ -87,12 +87,17 @@
@if ($image['containers'] === []) - - Delete - + @can('update', $server) + + @endcan @endif