mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
8.8 KiB
PHP
252 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Server;
|
|
|
|
use App\Models\Server;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
class DockerImages extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
use WithPagination;
|
|
|
|
public Server $server;
|
|
|
|
public array $parameters = [];
|
|
|
|
/**
|
|
* Images read from the server. Deletion checks container usage against this list.
|
|
*/
|
|
#[Locked]
|
|
public array $images = [];
|
|
|
|
public ?array $usage = null;
|
|
|
|
public string $search = '';
|
|
|
|
public bool $loaded = false;
|
|
|
|
public int $perPage = 10;
|
|
|
|
/**
|
|
* Column sort such as "size_desc". Empty keeps the order of `docker image ls` (newest first).
|
|
*/
|
|
public string $sort = '';
|
|
|
|
public function updatedSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedPerPage(): void
|
|
{
|
|
$this->perPage = max(1, min(100, $this->perPage));
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function setSort(string $sort): void
|
|
{
|
|
if (! array_key_exists(str($sort)->beforeLast('_')->value(), $this->sortColumns())
|
|
|| ! in_array(str($sort)->afterLast('_')->value(), ['asc', 'desc'], true)) {
|
|
return;
|
|
}
|
|
|
|
$this->sort = $sort;
|
|
$this->resetPage();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, \Closure(array): mixed>
|
|
*/
|
|
private function sortColumns(): array
|
|
{
|
|
return [
|
|
'repository' => fn (array $image) => $image['repository'].':'.$image['tag'],
|
|
'tag' => fn (array $image) => $image['tag'],
|
|
'id' => fn (array $image) => $image['short_id'],
|
|
'created' => fn (array $image) => $image['created_at'] ?? 0,
|
|
'size' => fn (array $image) => $image['size_bytes'] ?? 0,
|
|
'used' => fn (array $image) => implode(', ', $image['containers']),
|
|
];
|
|
}
|
|
|
|
public function mount(string $server_uuid)
|
|
{
|
|
try {
|
|
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
|
$this->authorize('view', $this->server);
|
|
$this->parameters = get_route_parameters();
|
|
} catch (\Throwable) {
|
|
return redirect()->route('server.index');
|
|
}
|
|
}
|
|
|
|
public function load(): void
|
|
{
|
|
$this->authorize('view', $this->server);
|
|
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'),
|
|
'size_bytes' => $this->sizeToBytes((string) data_get($image, 'Size')),
|
|
'created' => data_get($image, 'CreatedSince'),
|
|
// Docker prints "2024-05-01 10:00:00 +0000 UTC"; the trailing zone name breaks strtotime().
|
|
'created_at' => strtotime(str((string) data_get($image, 'CreatedAt'))->replaceMatches('/ [A-Z]+$/', '')->value()) ?: 0,
|
|
'containers' => $containersByImage[$id] ?? [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Docker prints sizes with decimal units, for example "512kB", "52.5MB" or "1.2GB".
|
|
*/
|
|
private function sizeToBytes(string $size): int
|
|
{
|
|
if (! preg_match('/^([\d.]+)\s*([kKMGTP]?)B$/', trim($size), $matches)) {
|
|
return 0;
|
|
}
|
|
$exponent = strpos('KMGTP', strtoupper($matches[2]));
|
|
|
|
return (int) round((float) $matches[1] * 1000 ** ($matches[2] === '' ? 0 : $exponent + 1));
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
$column = $this->sortColumns()[str($this->sort)->beforeLast('_')->value()] ?? null;
|
|
if ($column) {
|
|
$images = $images->sortBy(
|
|
fn (array $image) => [$column($image), $image['reference']],
|
|
descending: str($this->sort)->endsWith('_desc'),
|
|
);
|
|
}
|
|
|
|
$this->perPage = max(1, min(100, $this->perPage));
|
|
$lastPage = max(1, (int) ceil($images->count() / $this->perPage));
|
|
$page = max(1, min((int) $this->getPage(), $lastPage));
|
|
if ($page !== $this->getPage()) {
|
|
$this->setPage($page);
|
|
}
|
|
|
|
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' => new LengthAwarePaginator(
|
|
$images->forPage($page, $this->perPage)->values(),
|
|
$images->count(),
|
|
$this->perPage,
|
|
$page,
|
|
),
|
|
]);
|
|
}
|
|
}
|