From 362a18e91aea02816f377017b32198e6f838bca4 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 11 Jul 2026 02:23:28 -0400 Subject: [PATCH] feat(resources): show multi-stack usedByStacks on images (#1612) Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change. --- .../src/__tests__/docker-controller.test.ts | 28 +++++++ backend/src/services/DockerController.ts | 20 +++-- docs/features/resources.mdx | 4 +- frontend/src/components/ResourcesView.tsx | 73 +++++++++++++------ .../__tests__/ResourcesView.test.tsx | 1 + .../resources/ImageDetailsSheet.tsx | 52 +++++++++++-- .../__tests__/ImageDetailsSheet.test.tsx | 61 ++++++++++++++++ 7 files changed, 205 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 62086ece..18b23984 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -431,6 +431,10 @@ describe('DockerController - pruneDanglingImages', () => { // ── getClassifiedResources ───────────────────────────────────────────── describe('DockerController - getClassifiedResources', () => { + beforeEach(() => { + CacheService.getInstance().invalidate('project-name-map'); + }); + it('classifies managed and unmanaged images', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img1', RepoTags: ['nginx:latest'], Size: 100, Containers: 1 }, @@ -450,12 +454,36 @@ describe('DockerController - getClassifiedResources', () => { const managed = result.images.find(i => i.Id === 'img1'); expect(managed!.managedStatus).toBe('managed'); expect(managed!.managedBy).toBe('my-stack'); + expect(managed!.usedByStacks).toEqual(['my-stack']); const unmanaged = result.images.find(i => i.Id === 'img2'); expect(unmanaged!.managedStatus).toBe('unmanaged'); + expect(unmanaged!.usedByStacks).toEqual([]); const unused = result.images.find(i => i.Id === 'img3'); expect(unused!.managedStatus).toBe('unused'); + expect(unused!.usedByStacks).toEqual([]); + }); + + it('aggregates multi-stack usedByStacks with stable sort and managedBy first entry', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-shared', RepoTags: ['postgres:16'], Size: 100, Containers: 2 }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'zeta' } }, + { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } }, + { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } }, + ]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['alpha', 'zeta']); + const img = result.images.find(i => i.Id === 'img-shared'); + + expect(img!.usedByStacks).toEqual(['alpha', 'zeta']); + expect(img!.managedBy).toBe('alpha'); + expect(img!.managedStatus).toBe('managed'); }); it('classifies system networks', async () => { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 816869e7..9ba73ad4 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -133,6 +133,9 @@ export interface ClassifiedImage { RepoTags: string[]; Size: number; Containers: number; + /** Deduped, localeCompare-sorted Sencho stacks that currently use this image. */ + usedByStacks: string[]; + /** First entry of usedByStacks, or null when unused / unmanaged-only. */ managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; @@ -482,29 +485,34 @@ class DockerController { const absDirToStack = DockerController.buildAbsDirMap(knownStackNames); const resolvedBase = path.resolve(COMPOSE_DIR); - // Build imageId → stack mapping using the full fallback resolution chain - const imageToStack = new Map(); + // Build imageId → set of Sencho stacks (multi-stack reverse index). + const imageToStacks = new Map>(); for (const c of allContainers as any[]) { if (!c.ImageID) continue; const stack = DockerController.resolveContainerStack( c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); - if (stack) imageToStack.set(c.ImageID, stack); + if (!stack) continue; + const set = imageToStacks.get(c.ImageID) ?? new Set(); + set.add(stack); + imageToStacks.set(c.ImageID, set); } const selfIdentity = SelfIdentityService.getInstance(); const images: ClassifiedImage[] = this.validateApiData(rawImages).map((img: any) => { - const stack = imageToStack.get(img.Id) ?? null; + const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b)); + const managedBy = usedByStacks[0] ?? null; const managedStatus: ClassifiedImage['managedStatus'] = img.Containers === 0 ? 'unused' : - stack ? 'managed' : 'unmanaged'; + managedBy ? 'managed' : 'unmanaged'; return { Id: img.Id, RepoTags: img.RepoTags ?? [], Size: img.Size ?? 0, Containers: img.Containers ?? 0, - managedBy: stack, + usedByStacks, + managedBy, managedStatus, isSencho: selfIdentity.isOwnImage(img.Id), }; diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 580d8bf0..d55611b0 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -72,7 +72,7 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta **Status column** combines two pieces of information per row: -- A usage badge: `In Use` plus the stack name when the image runs in a Sencho-managed stack, `In Use` plus `External` when it runs in another Docker project, or `Unused` when no container references it. +- A usage badge: `In Use` plus one chip per Sencho stack that uses the image (click a chip to open that stack), `In Use` plus `External` when it runs only in another Docker project, or `Unused` when no container references it. - A severity badge from any image scan: `Clean`, `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`, or `UNKNOWN`. Click the badge to open the scan results. **Per-row actions** sit on the right: @@ -85,7 +85,7 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta Click the eye icon on any image row to open a detail sheet with three sections: -- **Overview** lists the ID, size, creation date, architecture and OS, author, and all repository tags. +- **Overview** lists the ID, size, creation date, architecture and OS, author, all repository tags, and which Sencho stacks use the image. - **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. diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 473518e4..266863ed 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -63,6 +63,7 @@ interface DockerImage { RepoTags: string[]; Size: number; Containers: number; + usedByStacks: string[]; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; @@ -227,30 +228,41 @@ function FilterToggle({ value, onChange, counts }: FilterToggleProps) { // ── Managed Status Badge ─────────────────────────────────────────────────────── -function ManagedBadge({ status, managedBy, onOpenStack }: { +function ManagedBadge({ status, managedBy, usedByStacks, onOpenStack }: { status: 'managed' | 'unmanaged' | 'unused' | 'system'; managedBy: string | null; + /** When length > 1, render one chip per stack (images shared across stacks). */ + usedByStacks?: string[]; /** When provided on a managed resource, the owning-stack badge becomes a link to that stack. */ onOpenStack?: (stack: string) => void; }) { if (status === 'managed') { + const stacks = (usedByStacks && usedByStacks.length > 0) + ? usedByStacks + : (managedBy ? [managedBy] : []); const cls = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium"; - const inner = (<>{managedBy}); - if (onOpenStack && managedBy) { - return ( - - - - - - Open stack {managedBy} - - - ); - } - return {inner}; + return ( + + {stacks.map((stack) => { + const inner = (<>{stack}); + if (onOpenStack) { + return ( + + + + + + Open stack {stack} + + + ); + } + return {inner}; + })} + + ); } if (status === 'unmanaged') { return ( @@ -429,7 +441,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const [showCreateNetwork, setShowCreateNetwork] = useState(false); const [inspectNetwork, setInspectNetwork] = useState(null); const [inspectLoadingId, setInspectLoadingId] = useState(null); - const [inspectImageId, setInspectImageId] = useState(null); + // Classified image selection is node-bound so a node switch cannot leave + // the previous node's usedByStacks visible beside a new node's inspect. + const [inspectImage, setInspectImage] = useState<(DockerImage & { nodeId: string | number }) | null>(null); const [browseVolume, setBrowseVolume] = useState(null); // Unmanaged container state @@ -502,6 +516,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} // Load the per-node reclaim-banner dismiss snapshot when the node changes. useEffect(() => { setHeroDismissedBytes(readHeroDismissed(activeNode?.id)); + setInspectImage(null); }, [activeNode?.id]); // Cancel an in-flight scan poll on unmount or node switch; its result @@ -1069,7 +1084,14 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} 0 ? "default" : "secondary"} className="text-[10px] h-5"> {img.Containers > 0 ? "In Use" : "Unused"} - + window.dispatchEvent( + new CustomEvent(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }), + ) : undefined} + /> {img.isSencho && } {(() => { const tag = img.RepoTags?.[0]; @@ -1088,7 +1110,10 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors" - onClick={() => setInspectImageId(img.Id)} + onClick={() => { + if (!activeNode) return; + setInspectImage({ ...img, nodeId: activeNode.id }); + }} aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`} > @@ -1640,7 +1665,13 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} /> {/* Image Details Sheet */} - setInspectImageId(null)} /> + setInspectImage(null)} + onOpenStack={activeNode ? (stack) => window.dispatchEvent( + new CustomEvent(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }), + ) : undefined} + /> {/* Volume Browser Sheet */} setBrowseVolume(null)} /> diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index 8bdb7016..8faec746 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -86,6 +86,7 @@ function image(repoTag: string) { RepoTags: [repoTag], Size: 1000, Containers: 0, + usedByStacks: [], managedBy: null, managedStatus: 'unmanaged' as const, isSencho: false, diff --git a/frontend/src/components/resources/ImageDetailsSheet.tsx b/frontend/src/components/resources/ImageDetailsSheet.tsx index 1088ce46..1d39d8e6 100644 --- a/frontend/src/components/resources/ImageDetailsSheet.tsx +++ b/frontend/src/components/resources/ImageDetailsSheet.tsx @@ -44,9 +44,23 @@ interface ImageDetails { history: ImageHistoryEntry[]; } +/** Classified image row passed from Resources (node-bound). */ +export interface ClassifiedImageSelection { + Id: string; + RepoTags: string[]; + Size: number; + Containers: number; + usedByStacks: string[]; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'unused'; + isSencho: boolean; + nodeId: string | number; +} + interface ImageDetailsSheetProps { - imageId: string | null; + image: ClassifiedImageSelection | null; onClose: () => void; + onOpenStack?: (stack: string) => void; } function formatRelativeAge(timestampSec: number): string { @@ -60,7 +74,8 @@ function formatRelativeAge(timestampSec: number): string { return `${Math.floor(diff / (86400 * 365))}y ago`; } -export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) { +export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsSheetProps) { + const imageId = image?.Id ?? null; const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -98,11 +113,14 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) const inspect = data?.inspect; const history = data?.history ?? []; const totalLayers = history.length; + const usedByStacks = image?.usedByStacks ?? []; - const name = inspect?.RepoTags?.[0] || (inspect ? formatShortDigest(inspect.Id) : 'Image details'); + 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…' : ''); + : (loading ? 'Loading…' : (image ? formatBytes(image.Size) : '')); const footerContext = inspect?.Created ? `Created ${formatRelativeAge(new Date(inspect.Created).getTime() / 1000)}` @@ -110,7 +128,7 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) return ( !open && onClose()} crumb={['Resources', 'Images', name]} name={name} @@ -173,6 +191,30 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) )} + + {usedByStacks.length === 0 ? ( +

+ {image?.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'} +

+ ) : ( +
+ {usedByStacks.map((stack) => ( + onOpenStack ? ( + + ) : ( + {stack} + ) + ))} +
+ )} +
diff --git a/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx b/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx new file mode 100644 index 00000000..88f558db --- /dev/null +++ b/frontend/src/components/resources/__tests__/ImageDetailsSheet.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ImageDetailsSheet } from '../ImageDetailsSheet'; + +const apiFetch = vi.fn(); +vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +const baseImage = { + Id: 'sha256:abc123', + RepoTags: ['postgres:16'], + Size: 1000, + Containers: 2, + usedByStacks: ['alpha', 'zeta'], + managedBy: 'alpha', + managedStatus: 'managed' as const, + isSencho: false, + nodeId: 1, +}; + +describe('ImageDetailsSheet', () => { + beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + inspect: { + Id: 'sha256:abc123', + RepoTags: ['postgres:16'], + Created: '2026-01-01T00:00:00Z', + Size: 1000, + Architecture: 'amd64', + Os: 'linux', + Config: {}, + }, + history: [], + }), + }); + }); + + it('renders Used by chips from the classified image prop', async () => { + const onOpenStack = vi.fn(); + render( + {}} onOpenStack={onOpenStack} />, + ); + + await waitFor(() => expect(screen.getByText('alpha')).toBeInTheDocument()); + expect(screen.getByText('zeta')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'alpha' })); + expect(onOpenStack).toHaveBeenCalledWith('alpha'); + }); + + it('stays closed when image is null', () => { + const { container } = render( {}} />); + expect(container.querySelector('[data-state="open"]')).toBeNull(); + }); +});