diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 7245e97d..76bfa91d 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -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', () => { diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 7bbf6ebb..c522e794 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -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; + 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 { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 35657888..861eee94 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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 }); diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 84d34276..f900e43e 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -57,6 +57,16 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta Click the trash icon on any row to delete an individual image. Sencho will warn you if the image is in use. +#### Inspect image + +Click the eye icon on any image row to open a detail panel showing: + +- **Overview** - ID, size, creation date, architecture and OS, author, and all repository tags +- **Config** - Default `Cmd`, `Entrypoint`, `WorkingDir`, `User`, exposed ports, environment variables, and labels (env and labels are collapsible) +- **Layers** - Ordered layer history. Each row shows layer index, size, age, and the build command (`CreatedBy`). Empty layers (metadata-only, zero bytes) are dimmed. + +The inspect panel is read-only and available to all users. + ### Volumes Lists all Docker volumes. Columns: name, driver, mount point, size, and managed status. diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 165ac225..2ab1c96e 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -37,6 +37,7 @@ import type { SenchoOpenLogsDetail } from '@/lib/events'; import { lazy, Suspense } from 'react'; import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; +import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; import { TabLanding, type TabLandingEntry } from './resources/TabLanding'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); @@ -392,6 +393,7 @@ export default function ResourcesView() { const [isCreatingNetwork, setIsCreatingNetwork] = useState(false); const [inspectNetwork, setInspectNetwork] = useState(null); const [inspectLoadingId, setInspectLoadingId] = useState(null); + const [inspectImageId, setInspectImageId] = useState(null); // Unmanaged container state const [selectedOrphans, setSelectedOrphans] = useState([]); @@ -858,6 +860,16 @@ export default function ResourcesView() {
+ {trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( @@ -1333,6 +1345,9 @@ export default function ResourcesView() { + {/* Image Details Sheet */} + setInspectImageId(null)} /> + {/* Network Inspect Sheet */} !open && setInspectNetwork(null)}> diff --git a/frontend/src/components/resources/ImageDetailsSheet.tsx b/frontend/src/components/resources/ImageDetailsSheet.tsx new file mode 100644 index 00000000..4fda073d --- /dev/null +++ b/frontend/src/components/resources/ImageDetailsSheet.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; +import { formatBytes } from '@/lib/utils'; +import { copyToClipboard } from '@/lib/clipboard'; +import { Image as ImageIcon, Copy } from 'lucide-react'; + +interface ImageInspect { + Id: string; + RepoTags?: string[] | null; + RepoDigests?: string[] | null; + Created: string; + Size: number; + Architecture?: string; + Os?: string; + Author?: string; + Config?: { + Cmd?: string[] | null; + Entrypoint?: string[] | null; + WorkingDir?: string; + User?: string; + Env?: string[] | null; + Labels?: Record | null; + ExposedPorts?: Record | null; + }; + RootFS?: { Type?: string; Layers?: string[] }; +} + +interface ImageHistoryEntry { + Id: string; + Created: number; + CreatedBy: string; + Tags?: string[] | null; + Size: number; + Comment?: string; +} + +interface ImageDetails { + inspect: ImageInspect; + history: ImageHistoryEntry[]; +} + +interface ImageDetailsSheetProps { + imageId: string | null; + onClose: () => void; +} + +function formatRelativeAge(timestampSec: number): string { + const now = Date.now() / 1000; + const diff = now - timestampSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`; + if (diff < 86400 * 365) return `${Math.floor(diff / (86400 * 30))}mo ago`; + return `${Math.floor(diff / (86400 * 365))}y ago`; +} + +function shortDigest(id: string): string { + const colon = id.indexOf(':'); + const hex = colon >= 0 ? id.slice(colon + 1) : id; + return hex.substring(0, 12); +} + +export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!imageId) { + setData(null); + return; + } + let cancelled = false; + setLoading(true); + setData(null); + + apiFetch(`/system/images/${encodeURIComponent(imageId)}`) + .then(async (res) => { + if (!res.ok) { + throw new Error(res.status === 404 ? 'Image not found.' : 'Failed to load image details.'); + } + return res.json() as Promise; + }) + .then((details) => { + if (!cancelled) setData(details); + }) + .catch((err: unknown) => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : 'Failed to load image details.'; + toast.error(msg); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [imageId]); + + const inspect = data?.inspect; + const history = data?.history ?? []; + const totalLayers = history.length; + + return ( + !open && onClose()}> + + + + + + {inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details')} + + + + {loading && ( +
+ + + + +
+ )} + + {!loading && inspect && ( +
+
+
+ +

+ {shortDigest(inspect.Id)} + +

+
+ +

{formatBytes(inspect.Size)}

+
+ +

+ {new Date(inspect.Created).toLocaleDateString()} +

+
+ +

+ {inspect.Architecture ?? 'unknown'} / {inspect.Os ?? 'unknown'} +

+
+ {inspect.Author && ( + +

{inspect.Author}

+
+ )} + {inspect.RepoTags && inspect.RepoTags.length > 0 && ( + +
+ {inspect.RepoTags.map((t) => ( + {t} + ))} +
+
+ )} +
+
+ + {inspect.Config && ( +
+
+ + + + + + {inspect.Config.Env && inspect.Config.Env.length > 0 && ( + + )} + {inspect.Config.Labels && Object.keys(inspect.Config.Labels).length > 0 && ( + `${k}=${v}`)} + /> + )} +
+
+ )} + +
+ {totalLayers === 0 ? ( +

No layer history available.

+ ) : ( +
    + {history.map((h, idx) => { + const empty = h.Size === 0; + return ( +
  1. +
    + #{totalLayers - idx} + {formatBytes(h.Size)} + {formatRelativeAge(h.Created)} +
    +

    + {h.CreatedBy || '(no command)'} +

    + {h.Comment && ( +

    {h.Comment}

    + )} +
  2. + ); + })} +
+ )} +
+
+ )} +
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Field({ label, children, span }: { label: string; children: React.ReactNode; span?: 1 | 2 }) { + return ( +
+ {label} + {children} +
+ ); +} + +function ConfigRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+ {label} + {value} +
+ ); +} + +function CollapsibleList({ label, count, items }: { label: string; count: number; items: string[] }) { + const [open, setOpen] = useState(false); + return ( +
+ {label} ({count}) +
+ + {open && ( +
    + {items.map((item, i) => ( +
  1. {item}
  2. + ))} +
+ )} +
+
+ ); +}