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', () => {
+19
View File
@@ -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 {
+6
View File
@@ -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 });
+10
View File
@@ -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.
+15
View File
@@ -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<NetworkInspectData | null>(null);
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
const [inspectImageId, setInspectImageId] = useState<string | null>(null);
// Unmanaged container state
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
@@ -858,6 +860,16 @@ export default function ResourcesView() {
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setInspectImageId(img.Id)}
title="Inspect image"
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
>
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1333,6 +1345,9 @@ export default function ResourcesView() {
</DialogContent>
</Dialog>
{/* Image Details Sheet */}
<ImageDetailsSheet imageId={inspectImageId} onClose={() => setInspectImageId(null)} />
{/* Network Inspect Sheet */}
<Sheet open={!!inspectNetwork} onOpenChange={open => !open && setInspectNetwork(null)}>
<SheetContent className="sm:max-w-lg">
@@ -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<string, string> | null;
ExposedPorts?: Record<string, unknown> | 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<ImageDetails | null>(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<ImageDetails>;
})
.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 (
<Sheet open={!!imageId} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="sm:max-w-lg">
<ScrollArea className="h-full">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
{inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details')}
</SheetTitle>
</SheetHeader>
{loading && (
<div className="space-y-3 mt-6">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
</div>
)}
{!loading && inspect && (
<div className="space-y-6 mt-6 pb-6">
<Section title="Overview">
<div className="grid grid-cols-2 gap-3 text-sm">
<Field label="ID">
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{shortDigest(inspect.Id)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={async () => {
try { await copyToClipboard(inspect.Id); toast.success('ID copied'); }
catch { toast.error('Copy failed.'); }
}}
aria-label="Copy image ID"
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</Field>
<Field label="Size">
<p className="font-mono text-xs mt-0.5 tabular-nums">{formatBytes(inspect.Size)}</p>
</Field>
<Field label="Created">
<p className="text-xs mt-0.5" title={new Date(inspect.Created).toLocaleString()}>
{new Date(inspect.Created).toLocaleDateString()}
</p>
</Field>
<Field label="Arch / OS">
<p className="text-xs mt-0.5">
<Badge variant="outline" className="text-[10px] h-5">{inspect.Architecture ?? 'unknown'} / {inspect.Os ?? 'unknown'}</Badge>
</p>
</Field>
{inspect.Author && (
<Field label="Author" span={2}>
<p className="text-xs mt-0.5">{inspect.Author}</p>
</Field>
)}
{inspect.RepoTags && inspect.RepoTags.length > 0 && (
<Field label="Tags" span={2}>
<div className="flex flex-wrap gap-1 mt-1">
{inspect.RepoTags.map((t) => (
<Badge key={t} variant="outline" className="text-[10px] h-5 font-mono">{t}</Badge>
))}
</div>
</Field>
)}
</div>
</Section>
{inspect.Config && (
<Section title="Config">
<div className="space-y-2 text-sm">
<ConfigRow label="Cmd" value={inspect.Config.Cmd?.join(' ')} />
<ConfigRow label="Entrypoint" value={inspect.Config.Entrypoint?.join(' ')} />
<ConfigRow label="WorkingDir" value={inspect.Config.WorkingDir} />
<ConfigRow label="User" value={inspect.Config.User} />
<ConfigRow
label="Ports"
value={
inspect.Config.ExposedPorts
? Object.keys(inspect.Config.ExposedPorts).join(', ')
: undefined
}
/>
{inspect.Config.Env && inspect.Config.Env.length > 0 && (
<CollapsibleList label="Env" count={inspect.Config.Env.length} items={inspect.Config.Env} />
)}
{inspect.Config.Labels && Object.keys(inspect.Config.Labels).length > 0 && (
<CollapsibleList
label="Labels"
count={Object.keys(inspect.Config.Labels).length}
items={Object.entries(inspect.Config.Labels).map(([k, v]) => `${k}=${v}`)}
/>
)}
</div>
</Section>
)}
<Section title={`Layers (${totalLayers})`}>
{totalLayers === 0 ? (
<p className="text-xs text-muted-foreground italic">No layer history available.</p>
) : (
<ol className="space-y-1.5">
{history.map((h, idx) => {
const empty = h.Size === 0;
return (
<li
key={`${h.Id}-${idx}`}
className={`rounded-md border border-card-border bg-card px-3 py-2 shadow-card-bevel ${empty ? 'opacity-60' : ''}`}
>
<div className="flex items-baseline justify-between gap-3 text-[11px] text-muted-foreground tabular-nums">
<span>#{totalLayers - idx}</span>
<span className="font-mono">{formatBytes(h.Size)}</span>
<span>{formatRelativeAge(h.Created)}</span>
</div>
<p
className="font-mono text-[11px] mt-1 break-all"
title={h.CreatedBy}
>
{h.CreatedBy || '(no command)'}
</p>
{h.Comment && (
<p className="text-[11px] text-muted-foreground italic mt-0.5">{h.Comment}</p>
)}
</li>
);
})}
</ol>
)}
</Section>
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{title}</h4>
{children}
</div>
);
}
function Field({ label, children, span }: { label: string; children: React.ReactNode; span?: 1 | 2 }) {
return (
<div className={span === 2 ? 'col-span-2' : undefined}>
<span className="text-xs text-muted-foreground">{label}</span>
{children}
</div>
);
}
function ConfigRow({ label, value }: { label: string; value?: string }) {
if (!value) return null;
return (
<div className="grid grid-cols-[100px_1fr] gap-3 items-baseline">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-mono text-[11px] break-all">{value}</span>
</div>
);
}
function CollapsibleList({ label, count, items }: { label: string; count: number; items: string[] }) {
const [open, setOpen] = useState(false);
return (
<div className="grid grid-cols-[100px_1fr] gap-3 items-baseline">
<span className="text-xs text-muted-foreground">{label} ({count})</span>
<div>
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
onClick={() => setOpen((v) => !v)}
>
{open ? 'Collapse' : 'Expand'}
</button>
{open && (
<ol className="mt-1.5 space-y-0.5">
{items.map((item, i) => (
<li key={i} className="font-mono text-[11px] break-all">{item}</li>
))}
</ol>
)}
</div>
</div>
);
}