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