mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -135,6 +135,25 @@ systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
systemMaintenanceRouter.get('/images/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rawId = req.params.id as string;
|
||||
if (!rawId) return res.status(400).json({ error: 'Invalid image ID format' });
|
||||
const hexId = rawId.startsWith('sha256:') ? rawId.slice('sha256:'.length) : rawId;
|
||||
if (!isValidDockerResourceId(hexId)) {
|
||||
return res.status(400).json({ error: 'Invalid image ID format' });
|
||||
}
|
||||
const result = await DockerController.getInstance(req.nodeId).inspectImage(hexId);
|
||||
res.json(result);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to inspect image:', error);
|
||||
const err = error as Record<string, unknown>;
|
||||
const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404)
|
||||
|| (error instanceof Error && error.message.includes('404'));
|
||||
res.status(is404 ? 404 : 500).json({ error: is404 ? 'Image not found' : 'Failed to inspect image' });
|
||||
}
|
||||
});
|
||||
|
||||
systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
|
||||
@@ -435,6 +435,12 @@ class DockerController {
|
||||
await image.remove({ force: true });
|
||||
}
|
||||
|
||||
public async inspectImage(id: string) {
|
||||
const image = this.docker.getImage(id);
|
||||
const [inspect, history] = await Promise.all([image.inspect(), image.history()]);
|
||||
return { inspect, history };
|
||||
}
|
||||
|
||||
public async removeVolume(name: string) {
|
||||
const volume = this.docker.getVolume(name);
|
||||
await volume.remove({ force: true });
|
||||
|
||||
Reference in New Issue
Block a user