feat(resources): add image details sheet with layer history (#925)

Adds a read-only inspect panel for Docker images. Click the eye icon on
any image row to open a sheet showing:

- Overview: ID (with copy), size, created date, arch/OS, author, tags
- Config: Cmd, Entrypoint, WorkingDir, User, exposed ports, env (collapsible),
  labels (collapsible)
- Layers: ordered history list with size, age, and build command per layer.
  Empty layers (metadata-only) are dimmed.

Backend adds DockerController.inspectImage(id) which combines image.inspect()
and image.history() in parallel, exposed via GET /api/system/images/:id.
The route accepts both bare hex IDs and sha256-prefixed IDs, since the list
endpoint surfaces the prefixed form. Returns 400 for malformed IDs and 404
for missing images.

Documents the new panel in docs/features/resources.mdx under Images.
This commit is contained in:
Anso
2026-05-04 23:45:54 -04:00
committed by GitHub
parent d73ae59ab8
commit 7e5dc2d9ea
6 changed files with 399 additions and 0 deletions
@@ -562,6 +562,59 @@ describe('DockerController - inspectNetwork edge cases', () => {
});
});
// --- inspectImage --------------------------------------------------------------
describe('DockerController - inspectImage', () => {
it('returns combined inspect + history payload', async () => {
const inspectData = {
Id: 'sha256:abc12345',
RepoTags: ['nginx:1.27'],
Size: 187_000_000,
Architecture: 'amd64',
Os: 'linux',
Config: { Cmd: ['nginx', '-g', 'daemon off;'] },
};
const historyData = [
{ Id: 'layer1', Created: 1700000000, CreatedBy: '/bin/sh -c #(nop) ADD file:abc', Size: 72_000_000, Tags: null, Comment: '' },
{ Id: 'layer2', Created: 1700000010, CreatedBy: 'ENV NGINX_VERSION=1.27', Size: 0, Tags: null, Comment: '' },
];
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue(inspectData),
history: vi.fn().mockResolvedValue(historyData),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImage('sha256:abc12345');
expect(result.inspect).toEqual(inspectData);
expect(result.history).toEqual(historyData);
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:abc12345');
});
it('propagates 404 from Dockerode when image is missing', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image: missing'), { statusCode: 404 })),
history: vi.fn().mockResolvedValue([]),
});
const dc = DockerController.getInstance(1);
await expect(dc.inspectImage('missing')).rejects.toThrow('No such image');
});
it('returns empty history when an image has none', async () => {
mockDocker.getImage.mockReturnValue({
inspect: vi.fn().mockResolvedValue({ Id: 'sha256:empty', Size: 0 }),
history: vi.fn().mockResolvedValue([]),
});
const dc = DockerController.getInstance(1);
const result = await dc.inspectImage('sha256:empty');
expect(result.history).toEqual([]);
expect(result.inspect.Id).toBe('sha256:empty');
});
});
// --- createNetwork validation --------------------------------------------------
describe('createNetwork', () => {