Merge remote-tracking branch 'origin/main'

This commit is contained in:
Andras Bacsai
2026-09-28 14:29:56 +02:00
6 changed files with 389 additions and 0 deletions
+161
View File
@@ -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(),
]);
}
}
+5
View File
@@ -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>
+2
View File
@@ -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');
+103
View File
@@ -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));
});