From 7e5dc2d9ea4f1e1e458516b5bb4fc62a797c8ee3 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 4 May 2026 23:45:54 -0400 Subject: [PATCH] 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. --- .../src/__tests__/docker-controller.test.ts | 53 ++++ backend/src/routes/systemMaintenance.ts | 19 ++ backend/src/services/DockerController.ts | 6 + docs/features/resources.mdx | 10 + frontend/src/components/ResourcesView.tsx | 15 + .../resources/ImageDetailsSheet.tsx | 296 ++++++++++++++++++ 6 files changed, 399 insertions(+) create mode 100644 frontend/src/components/resources/ImageDetailsSheet.tsx 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. + ))} +
+ )} +
+
+ ); +}