Files
2026-10-29 21:10:39 +01:00

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,
),
]);
}
}