feat: add inspect image action on stack container cards (#1780)

Open the existing image detail sheet from each container card, between View logs and Monitor, so operators can inspect a container's image without leaving stack details.
This commit is contained in:
Anso
2026-08-05 18:10:11 -04:00
committed by GitHub
parent 64e45260f1
commit 4e54a9272d
15 changed files with 324 additions and 19 deletions
+1
View File
@@ -700,6 +700,7 @@ export default function EditorLayout() {
changeEnvFile={stackActions.changeEnvFile}
openLogViewer={stackActions.openLogViewer}
openBashModal={stackActions.openBashModal}
onInspectImage={selectedFile && activeNode?.id != null ? stackActions.openInspectImage : undefined}
onOpenMonitor={stackName ? () => overlayState.openAlertSheet(stackName) : undefined}
onOpenServiceMonitor={stackName
? (serviceName) => overlayState.openAlertSheet(stackName, { serviceName })
@@ -180,6 +180,7 @@ export interface EditorViewProps {
// Container / service actions
openLogViewer: (containerId: string, containerName: string) => void;
openBashModal: (containerId: string, containerName: string) => void;
onInspectImage?: (imageId: string, imageRef: string) => void;
onOpenMonitor?: () => void;
onOpenServiceMonitor?: (serviceName: string) => void;
serviceAction: (
@@ -287,6 +288,7 @@ export function EditorView(props: EditorViewProps) {
changeEnvFile,
openLogViewer,
openBashModal,
onInspectImage,
onOpenMonitor,
onOpenServiceMonitor,
serviceAction,
@@ -496,6 +498,7 @@ export function EditorView(props: EditorViewProps) {
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
onInspectImage={onInspectImage}
onOpenServiceMonitor={onOpenServiceMonitor}
serviceAction={serviceAction}
effectiveServices={effectiveServices}
@@ -523,6 +526,7 @@ export function EditorView(props: EditorViewProps) {
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
onInspectImage={onInspectImage}
onOpenServiceMonitor={onOpenServiceMonitor}
serviceAction={serviceAction}
effectiveServices={effectiveServices}
@@ -61,6 +61,7 @@ export function MobileStackDetail(props: EditorViewProps) {
changeEnvFile,
openLogViewer,
openBashModal,
onInspectImage,
onOpenMonitor,
onOpenServiceMonitor,
serviceAction,
@@ -233,6 +234,7 @@ export function MobileStackDetail(props: EditorViewProps) {
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
onInspectImage={onInspectImage}
onOpenServiceMonitor={onOpenServiceMonitor}
serviceAction={serviceAction}
effectiveServices={effectiveServices}
@@ -12,6 +12,7 @@ import { UnsavedChangesDialog } from './UnsavedChangesDialog';
import { StackAlertSheet } from '../StackAlertSheet';
import { GitSourcePanel } from '../stack/GitSourcePanel';
import { LogViewer } from '../LogViewer';
import { ImageDetailsSheet } from '../resources/ImageDetailsSheet';
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog';
import { resolveComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
@@ -71,6 +72,7 @@ export function ShellOverlays({
pendingUnsavedLoad, pendingLeaveAction,
bashModalOpen, selectedContainer,
logViewerOpen, logContainer,
inspectImage, closeInspectImage,
stackMonitor, closeStackMonitor,
policyBlock, setPolicyBlock, policyBypassing,
updateReadiness, setUpdateReadiness,
@@ -88,6 +90,12 @@ export function ShellOverlays({
const isTakeDownConfirming =
stackToTakeDown != null &&
stackActionMap[resolveStackFileKey(stackFiles, stackToTakeDown)] === 'down';
const sheetImage = inspectImage && inspectImage.nodeId === activeNodeId
? inspectImage
: null;
const inspectCrumb = sheetImage
? [sheetImage.usedByStacks[0] || 'Stack', sheetImage.RepoTags[0] || 'Image']
: undefined;
return (
<>
@@ -168,6 +176,12 @@ export function ShellOverlays({
/>
)}
<ImageDetailsSheet
image={sheetImage}
onClose={closeInspectImage}
crumb={inspectCrumb}
/>
{/* Stack monitor (alerts + auto-heal as tabs) */}
<StackAlertSheet
open={stackMonitor !== null}
@@ -672,6 +672,85 @@ describe('containers load states', () => {
});
});
describe('ContainersHealth inspect image icon', () => {
function inspectable(overrides: Partial<ContainerInfo> = {}): ContainerInfo {
return {
...container([{ PrivatePort: 80, PublicPort: 8080 }]),
ImageID: 'sha256:abc123',
Image: 'nginx:latest',
Service: 'web',
...overrides,
};
}
function renderInspect(containers: ContainerInfo[], props: Partial<ContainersHealthProps> = {}) {
return render(
<ContainersHealth
safeContainers={containers}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
onInspectImage={vi.fn()}
{...props}
/>,
);
}
it('places Inspect image between View logs and Monitor', () => {
renderInspect([inspectable()], { onOpenServiceMonitor: vi.fn() });
const logs = screen.getByRole('button', { name: 'View logs' });
const inspect = screen.getByRole('button', { name: 'Inspect image' });
const monitor = screen.getByRole('button', { name: 'Monitor web' });
expect(
logs.compareDocumentPosition(inspect) & globalThis.Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
inspect.compareDocumentPosition(monitor) & globalThis.Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('calls onInspectImage with ImageID and Image', async () => {
const onInspectImage = vi.fn();
const user = userEvent.setup();
renderInspect([inspectable()], { onInspectImage });
await user.click(screen.getByRole('button', { name: 'Inspect image' }));
expect(onInspectImage).toHaveBeenCalledWith('sha256:abc123', 'nginx:latest');
});
it('hides Inspect image when onInspectImage is omitted', () => {
renderInspect([inspectable()], { onInspectImage: undefined });
expect(screen.queryByRole('button', { name: 'Inspect image' })).toBeNull();
});
it('disables Inspect image when ImageID is missing', () => {
renderInspect([inspectable({ ImageID: undefined })]);
expect(screen.getByRole('button', { name: 'Inspect image' })).toBeDisabled();
});
it('keeps Inspect image enabled for a stopped container', async () => {
const onInspectImage = vi.fn();
const user = userEvent.setup();
renderInspect([inspectable({ State: 'exited', Status: 'Exited (0)' })], { onInspectImage });
const button = screen.getByRole('button', { name: 'Inspect image' });
expect(button).toBeEnabled();
await user.click(button);
expect(onInspectImage).toHaveBeenCalledWith('sha256:abc123', 'nginx:latest');
});
it('keeps Inspect image visible in compact mode', () => {
renderInspect([
inspectable({ Id: 'a', Names: ['/web'] }),
inspectable({ Id: 'b', Names: ['/db'], Service: 'db', ImageID: 'sha256:def' }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
expect(screen.getAllByRole('button', { name: 'Inspect image' })).toHaveLength(2);
});
});
describe('ContainersHealth Docker health status labels', () => {
const OLD_PHRASES = ['healthcheck passing', 'healthcheck failing', 'healthcheck starting'] as const;
const HEALTH_TOKENS = ['healthy', 'unhealthy', 'starting'] as const;
@@ -24,6 +24,7 @@ import {
AlertCircle,
RefreshCw,
HeartPulse,
Eye,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '../ui/button';
@@ -327,6 +328,7 @@ export interface ContainersHealthProps {
activeNode: Node | null;
openLogViewer: (containerId: string, containerName: string) => void;
openBashModal: (containerId: string, containerName: string) => void;
onInspectImage?: (imageId: string, imageRef: string) => void;
/** Opens Monitor (Alerts tab); preselects the Compose service in add forms when listed. */
onOpenServiceMonitor?: (serviceName: string) => void;
serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise<void>;
@@ -363,6 +365,7 @@ export function ContainersHealth({
activeNode,
openLogViewer,
openBashModal,
onInspectImage,
onOpenServiceMonitor,
serviceAction,
effectiveServices = [],
@@ -668,6 +671,28 @@ export function ContainersHealth({
<TooltipContent>View logs</TooltipContent>
</Tooltip>
</TooltipProvider>
{onInspectImage && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
onClick={() => {
if (!container.ImageID) return;
onInspectImage(container.ImageID, container.Image ?? '');
}}
disabled={!container.ImageID}
aria-label="Inspect image"
>
<Eye className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Inspect image</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{onOpenServiceMonitor && composeService && (
<TooltipProvider>
<Tooltip>
@@ -3,6 +3,13 @@ import { describe, it, expect } from 'vitest';
import { useOverlayState } from './useOverlayState';
describe('useOverlayState', () => {
const inspectSelection = {
Id: 'sha256:abc123',
RepoTags: ['nginx:latest'],
usedByStacks: ['web'],
nodeId: 1,
};
it('initialises with all overlays closed and null/empty data', () => {
const { result } = renderHook(() => useOverlayState());
expect(result.current.createDialogOpen).toBe(false);
@@ -15,6 +22,7 @@ describe('useOverlayState', () => {
expect(result.current.selectedContainer).toBeNull();
expect(result.current.logViewerOpen).toBe(false);
expect(result.current.logContainer).toBeNull();
expect(result.current.inspectImage).toBeNull();
expect(result.current.stackMonitor).toBeNull();
expect(result.current.policyBlock).toBeNull();
expect(result.current.policyBypassing).toBe(false);
@@ -68,6 +76,19 @@ describe('useOverlayState', () => {
expect(result.current.logContainer).toBeNull();
});
it('openInspectImage sets the selection', () => {
const { result } = renderHook(() => useOverlayState());
act(() => result.current.openInspectImage(inspectSelection));
expect(result.current.inspectImage).toEqual(inspectSelection);
});
it('closeInspectImage clears the selection', () => {
const { result } = renderHook(() => useOverlayState());
act(() => result.current.openInspectImage(inspectSelection));
act(() => result.current.closeInspectImage());
expect(result.current.inspectImage).toBeNull();
});
it('openAlertSheet opens stack monitor on the alerts tab', () => {
const { result } = renderHook(() => useOverlayState());
act(() => result.current.openAlertSheet('web-stack'));
@@ -25,6 +25,13 @@ type PolicyBlock = {
};
type Container = { id: string; name: string };
type InspectImageSelection = {
Id: string;
RepoTags: string[];
usedByStacks: string[];
nodeId: number;
};
type StackMonitorState = {
stackName: string;
tab: 'alerts' | 'auto-heal';
@@ -103,6 +110,10 @@ export function useOverlayState() {
setLogContainer(null);
}, []);
const [inspectImage, setInspectImage] = useState<InspectImageSelection | null>(null);
const openInspectImage = useCallback((image: InspectImageSelection) => setInspectImage(image), []);
const closeInspectImage = useCallback(() => setInspectImage(null), []);
// Listen for topology click-to-logs events and open the log viewer.
// openLogViewer is stable (useCallback with empty deps), so this effect
// mounts/unmounts once and never re-registers.
@@ -189,6 +200,7 @@ export function useOverlayState() {
pendingLeaveAction, setPendingLeaveAction,
bashModalOpen, selectedContainer, openBashModal, closeBashModal,
logViewerOpen, logContainer, openLogViewer, closeLogViewer,
inspectImage, openInspectImage, closeInspectImage,
stackMonitor, openAlertSheet, openAutoHeal, closeStackMonitor,
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
updateReadiness, setUpdateReadiness,
@@ -24,6 +24,8 @@ import { toast } from '@/components/ui/toast-store';
type EditorState = ReturnType<typeof useEditorViewState>;
type StackListState = ReturnType<typeof useStackListState>;
type NavState = ReturnType<typeof useViewNavigationState>;
type ActiveNode = Parameters<typeof useStackActions>[0]['activeNode'];
const DEFAULT_ACTIVE_NODE = { id: 1, name: 'Local', type: 'local' } as ActiveNode;
function makeEditorState(over: Partial<EditorState> = {}): EditorState {
const base = {
@@ -154,7 +156,7 @@ function setup(over: {
stackListState,
navState,
overlayState,
activeNode: over.activeNode ?? ({ id: 1, name: 'Local', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
activeNode: over.activeNode === undefined ? DEFAULT_ACTIVE_NODE : over.activeNode,
setActiveNode,
nodes: [],
runWithLog,
@@ -1929,3 +1931,72 @@ describe('useStackActions.deleteStack', () => {
expect(removeNotificationsForStack).not.toHaveBeenCalled();
});
});
describe('useStackActions.openInspectImage', () => {
it('builds a slim selection from the selected stack and active node', () => {
const openInspectImage = vi.fn();
const { result } = setup({
overlay: { openInspectImage },
stackList: { selectedFile: 'web.yml' },
activeNode: { id: 3, name: 'Local', type: 'local' } as ActiveNode,
});
act(() => {
result.current.openInspectImage('sha256:abc', 'nginx:latest');
});
expect(openInspectImage).toHaveBeenCalledWith({
Id: 'sha256:abc',
RepoTags: ['nginx:latest'],
usedByStacks: ['web'],
nodeId: 3,
});
});
it('strips yaml suffix from selectedFile for usedByStacks', () => {
const openInspectImage = vi.fn();
const { result } = setup({
overlay: { openInspectImage },
stackList: { selectedFile: 'api.yaml' },
});
act(() => {
result.current.openInspectImage('sha256:def', 'redis:7');
});
expect(openInspectImage).toHaveBeenCalledWith(expect.objectContaining({
usedByStacks: ['api'],
RepoTags: ['redis:7'],
}));
});
it('no-ops when no stack is selected', () => {
const openInspectImage = vi.fn();
const { result } = setup({
overlay: { openInspectImage },
stackList: { selectedFile: null },
});
act(() => {
result.current.openInspectImage('sha256:abc', 'nginx:latest');
});
expect(openInspectImage).not.toHaveBeenCalled();
});
it('no-ops when there is no active node', () => {
const openInspectImage = vi.fn();
const { result } = setup({
overlay: { openInspectImage },
stackList: { selectedFile: 'web.yml' },
activeNode: null,
});
act(() => {
result.current.openInspectImage('sha256:abc', 'nginx:latest');
});
expect(openInspectImage).not.toHaveBeenCalled();
});
});
@@ -2290,6 +2290,19 @@ export function useStackActions(options: UseStackActionsOptions) {
[overlayState.openLogViewer],
);
const closeLogViewer = overlayState.closeLogViewer;
const openInspectImage = useCallback(
(imageId: string, imageRef: string) => {
const file = stackListState.selectedFile;
if (!file || activeNode?.id == null) return;
overlayState.openInspectImage({
Id: imageId,
RepoTags: imageRef ? [imageRef] : [],
usedByStacks: [file.replace(/\.(ya?ml)$/, '')],
nodeId: activeNode.id,
});
},
[stackListState.selectedFile, activeNode?.id, overlayState.openInspectImage],
);
return {
pendingStackLoadRef,
@@ -2341,6 +2354,7 @@ export function useStackActions(options: UseStackActionsOptions) {
closeBashModal,
openLogViewer,
closeLogViewer,
openInspectImage,
isSelfStackFile,
openSelfStackProtectedIfNeeded,
};
@@ -46,16 +46,16 @@ interface ImageDetails {
history: ImageHistoryEntry[];
}
/** Classified image row passed from Resources (node-bound). */
/** Node-bound image selection (Resources row or stack-container inspect). */
export interface ClassifiedImageSelection {
Id: string;
RepoTags: string[];
Size: number;
Containers: number;
Size?: number;
Containers?: number;
usedByStacks: string[];
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'unused';
isSencho: boolean;
managedBy?: string | null;
managedStatus?: 'managed' | 'unmanaged' | 'unused';
isSencho?: boolean;
nodeId: string | number;
}
@@ -63,6 +63,8 @@ interface ImageDetailsSheetProps {
image: ClassifiedImageSelection | null;
onClose: () => void;
onOpenStack?: (stack: string) => void;
/** Optional breadcrumb. Defaults to Resources / Images / {image name}. */
crumb?: string[];
}
function formatRelativeAge(timestampSec: number): string {
@@ -76,7 +78,7 @@ function formatRelativeAge(timestampSec: number): string {
return `${Math.floor(diff / (86400 * 365))}y ago`;
}
export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsSheetProps) {
export function ImageDetailsSheet({ image, onClose, onOpenStack, crumb }: ImageDetailsSheetProps) {
const imageId = image?.Id ?? null;
const { isAdmin } = useAuth();
const [data, setData] = useState<ImageDetails | null>(null);
@@ -120,9 +122,14 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
const name = image?.RepoTags?.[0]
|| inspect?.RepoTags?.[0]
|| (inspect ? formatShortDigest(inspect.Id) : (imageId ? formatShortDigest(imageId) : 'Image details'));
const meta = inspect
? `${formatBytes(inspect.Size)} · ${inspect.Architecture ?? '?'}/${inspect.Os ?? '?'} · ${totalLayers} layers`
: (loading ? 'Loading…' : (image ? formatBytes(image.Size) : ''));
let meta = '';
if (inspect) {
meta = `${formatBytes(inspect.Size)} · ${inspect.Architecture ?? '?'}/${inspect.Os ?? '?'} · ${totalLayers} layers`;
} else if (loading) {
meta = 'Loading…';
} else if (image?.Size != null) {
meta = formatBytes(image.Size);
}
const footerContext = inspect?.Created
? `Created ${formatRelativeAge(new Date(inspect.Created).getTime() / 1000)}`
@@ -132,7 +139,7 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
<SystemSheet
open={!!image}
onOpenChange={(open) => !open && onClose()}
crumb={['Resources', 'Images', name]}
crumb={crumb ?? ['Resources', 'Images', name]}
name={name}
meta={meta}
footerContext={footerContext}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ImageDetailsSheet } from '../ImageDetailsSheet';
@@ -59,4 +59,58 @@ describe('ImageDetailsSheet', () => {
const { container } = render(<ImageDetailsSheet image={null} onClose={() => {}} />);
expect(container.querySelector('[data-state="open"]')).toBeNull();
});
it('defaults the crumb to Resources Images name', async () => {
render(<ImageDetailsSheet image={baseImage} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByRole('navigation', { name: 'Sheet location' })).toHaveTextContent(
'ResourcesImagespostgres:16',
);
});
});
it('uses a custom crumb when provided', async () => {
render(
<ImageDetailsSheet
image={baseImage}
onClose={() => {}}
crumb={['web', 'postgres:16']}
/>,
);
await waitFor(() => {
expect(screen.getByRole('navigation', { name: 'Sheet location' })).toHaveTextContent(
'webpostgres:16',
);
});
});
it('omits size from meta when inspect fails and Size is unknown', async () => {
apiFetch.mockResolvedValue({ ok: false, status: 404 });
const slimImage = {
Id: 'sha256:abc123',
RepoTags: ['nginx:latest'],
usedByStacks: ['web'],
nodeId: 1,
};
render(<ImageDetailsSheet image={slimImage} onClose={() => {}} crumb={['web', 'nginx:latest']} />);
await waitFor(() => {
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
});
expect(screen.queryByText(/0 Bytes/)).not.toBeInTheDocument();
});
it('renders Used by as a non-clickable badge when onOpenStack is omitted', async () => {
render(
<ImageDetailsSheet
image={{ Id: 'sha256:abc', RepoTags: ['nginx:latest'], usedByStacks: ['web'], nodeId: 1 }}
onClose={() => {}}
crumb={['web', 'nginx:latest']}
/>,
);
const heading = await screen.findByRole('heading', { name: 'Used by' });
const section = heading.closest('section');
if (!section) throw new Error('expected Used by section');
expect(within(section).getByText('web')).toBeInTheDocument();
expect(within(section).queryByRole('button', { name: 'web' })).toBeNull();
});
});