mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -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(
|
||||
'Resources›Images›postgres: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(
|
||||
'web›postgres: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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user