mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +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:
@@ -60,7 +60,7 @@ Below the action bar, the **CONTAINERS** section lists every container the stack
|
||||
| **Uptime / state** | `up 2h 15m` for running containers, the raw state for everything else. |
|
||||
| **Healthcheck label** | `healthy`, `unhealthy`, or `starting`, only when a healthcheck is defined. |
|
||||
| **Port mapping** | The first detected web-UI port, formatted `host → container/proto`. The mapping itself is a link that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. |
|
||||
| **Action buttons** | **Image source links**, **View logs**, **Open bash shell** (admin only), **Service actions**. Per-container image references and registry links live here, not in the header. |
|
||||
| **Action buttons** | **Image source links**, **View logs**, **Inspect image**, **Open bash shell** (admin only), **Service actions**. Per-container image references and registry links live here, not in the header. **Inspect image** opens the same image detail sheet as Resources; Used by shows the current stack only from this entry point. |
|
||||
| **Live stats** | CPU, memory, and net I/O with rolling sparklines. Only rendered while the container is running. Stats refresh on the same 1500 ms cadence as the dashboard. |
|
||||
|
||||
### Service actions
|
||||
|
||||
@@ -91,13 +91,13 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta
|
||||
|
||||
#### Inspect image
|
||||
|
||||
Click the eye icon on any image row to open a detail sheet with these sections:
|
||||
Click the eye icon on any image row to open a detail sheet with these sections. The same sheet is also available from each container card on the stack detail view (**Inspect image**, between View logs and Monitor). From that entry point, Used by lists the current stack only.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/resources/resources-image-inspect.png" alt="Image inspect sheet showing the Used by, Overview, Config, and Layers sections for a running image" />
|
||||
</Frame>
|
||||
|
||||
- **Used by** lists every Sencho stack that references the image (chips open that stack). Sourced from the Resources inventory, so it stays visible while inspect loads or if inspect fails.
|
||||
- **Used by** (from Resources) lists every Sencho stack that references the image; chips open that stack. Sourced from the Resources inventory, so it stays visible while inspect loads or if inspect fails. From a stack container card, Used by shows the current stack only, as a non-clickable badge.
|
||||
- **Overview** lists the ID (with a copy button), size, creation date, architecture and OS, author, and all repository tags.
|
||||
- **Config** shows the default `Cmd`, `Entrypoint`, `WorkingDir`, `User`, exposed ports, environment variables, and labels. Env vars and labels are collapsible.
|
||||
- **Layers** lists the layer history in build order. Each row shows the layer index, size, age, and the build command (`CreatedBy`). Empty layers (metadata-only, zero bytes) are dimmed.
|
||||
|
||||
@@ -223,13 +223,13 @@ Each row includes:
|
||||
- **Meta line.** Uptime (`up 12 hours`), the Docker health status when defined (`healthy`, `unhealthy`, or `starting`), and the primary port mapping (`8989 → 8989/tcp`).
|
||||
- **Open link.** When the container publishes a port, the mapping itself is a link (`8989 → 8989/tcp ↗`) that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. Recognised multi-port apps open their web path automatically (for example, Plex opens `/web`).
|
||||
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
|
||||
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the **Service actions** kebab on single-service stacks and single-container multi-service rows (multi-replica services keep the kebab on the shared header; see below).
|
||||
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, **Inspect image**, open a bash shell, and the **Service actions** kebab on single-service stacks and single-container multi-service rows (multi-replica services keep the kebab on the shared header; see below). **Inspect image** opens the same image detail sheet as [Resources](/features/resources#inspect-image). Used by shows the current stack only from this entry point.
|
||||
|
||||
### Multi-service stacks
|
||||
|
||||
When a stack declares more than one Compose service, each service with a single matching container renders as one flat container card. When a registry image update is confirmed for that service, the card shows **Update** immediately left of the image source links control (with an update badge). Build-backed services without a confirmed registry update show **Rebuild** in the same place. The **Service actions** kebab for Start / Stop / Restart stays on the card.
|
||||
|
||||
Services with multiple replicas keep a shared header above their nested container cards. That header owns the same **Update** / **Rebuild** rules, the update badge, and **Start / Stop / Restart** for every replica. Nested cards keep logs, shell, ports, and metrics only.
|
||||
Services with multiple replicas keep a shared header above their nested container cards. That header owns the same **Update** / **Rebuild** rules, the update badge, and **Start / Stop / Restart** for every replica. Nested cards keep logs, inspect image, shell, ports, and metrics only.
|
||||
|
||||
A declared service with no running containers shows a compact row with the service name, Update/Rebuild under the same rules, and the Service actions kebab (so you can still Start the service).
|
||||
|
||||
@@ -429,8 +429,9 @@ Press `B` again or toggle the bulk mode button to leave bulk mode.
|
||||
|
||||
## Controlling a single service
|
||||
|
||||
Inside the stack detail view, container cards expose quick actions (View logs, Monitor, Open bash when available). On single-service stacks, single-container multi-service rows, and empty declared-service rows, a **Service actions** kebab sits on that row. On multi-replica services the kebab lives on the shared service header only, not on each replica card. Use these actions to act on that service without touching the rest of the stack.
|
||||
Inside the stack detail view, container cards expose quick actions (View logs, Inspect image, Monitor, Open bash when available). On single-service stacks, single-container multi-service rows, and empty declared-service rows, a **Service actions** kebab sits on that row. On multi-replica services the kebab lives on the shared service header only, not on each replica card. Use these actions to act on that service without touching the rest of the stack.
|
||||
|
||||
- **Inspect image**: opens the same image detail sheet as [Resources](/features/resources#inspect-image) for that container's image. Used by shows the current stack only from this entry point.
|
||||
- **Monitor**: opens the stack **Monitor** sheet with that Compose service preferred in the add forms. Shown only when the container reports a Compose service name.
|
||||
- **Restart service**: stops and starts all containers for that service.
|
||||
- **Stop service**: stops all containers for that service. Only shown when the service is running.
|
||||
|
||||
@@ -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