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.
This commit is contained in:
Anso
2026-07-11 02:23:28 -04:00
committed by GitHub
parent 213d3d5d3a
commit 362a18e91a
7 changed files with 205 additions and 34 deletions
@@ -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 () => {
+14 -6
View File
@@ -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<string, string>();
// Build imageId → set of Sencho stacks (multi-stack reverse index).
const imageToStacks = new Map<string, Set<string>>();
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<string>();
set.add(stack);
imageToStacks.set(c.ImageID, set);
}
const selfIdentity = SelfIdentityService.getInstance();
const images: ClassifiedImage[] = this.validateApiData<any[]>(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),
};
+2 -2
View File
@@ -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.
+52 -21
View File
@@ -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 = (<><span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />{managedBy}</>);
if (onOpenStack && managedBy) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className={`${cls} hover:bg-success/15 transition-colors`} onClick={() => onOpenStack(managedBy)}>
{inner}
</button>
</TooltipTrigger>
<TooltipContent>Open stack {managedBy}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <span className={cls}>{inner}</span>;
return (
<span className="inline-flex items-center gap-1 flex-wrap">
{stacks.map((stack) => {
const inner = (<><span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />{stack}</>);
if (onOpenStack) {
return (
<TooltipProvider key={stack}>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className={`${cls} hover:bg-success/15 transition-colors`} onClick={() => onOpenStack(stack)}>
{inner}
</button>
</TooltipTrigger>
<TooltipContent>Open stack {stack}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <span key={stack} className={cls}>{inner}</span>;
})}
</span>
);
}
if (status === 'unmanaged') {
return (
@@ -429,7 +441,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
const [inspectImageId, setInspectImageId] = useState<string | null>(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<string | null>(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 = {}
<Badge variant={img.Containers > 0 ? "default" : "secondary"} className="text-[10px] h-5">
{img.Containers > 0 ? "In Use" : "Unused"}
</Badge>
<ManagedBadge status={img.managedStatus} managedBy={img.managedBy} />
<ManagedBadge
status={img.managedStatus}
managedBy={img.managedBy}
usedByStacks={img.usedByStacks}
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
) : undefined}
/>
{img.isSencho && <SenchoBadge />}
{(() => {
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'}`}
>
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
@@ -1640,7 +1665,13 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
/>
{/* Image Details Sheet */}
<ImageDetailsSheet imageId={inspectImageId} onClose={() => setInspectImageId(null)} />
<ImageDetailsSheet
image={inspectImage && activeNode && inspectImage.nodeId === activeNode.id ? inspectImage : null}
onClose={() => setInspectImage(null)}
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
) : undefined}
/>
{/* Volume Browser Sheet */}
<VolumeBrowserSheet volumeName={browseVolume} onClose={() => setBrowseVolume(null)} />
@@ -86,6 +86,7 @@ function image(repoTag: string) {
RepoTags: [repoTag],
Size: 1000,
Containers: 0,
usedByStacks: [],
managedBy: null,
managedStatus: 'unmanaged' as const,
isSencho: false,
@@ -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<ImageDetails | null>(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 (
<SystemSheet
open={!!imageId}
open={!!image}
onOpenChange={(open) => !open && onClose()}
crumb={['Resources', 'Images', name]}
name={name}
@@ -173,6 +191,30 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps)
</div>
</Field>
)}
<Field label="Used by" span={2}>
{usedByStacks.length === 0 ? (
<p className="text-xs mt-0.5 text-muted-foreground">
{image?.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
</p>
) : (
<div className="flex flex-wrap gap-1 mt-1">
{usedByStacks.map((stack) => (
onOpenStack ? (
<button
key={stack}
type="button"
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
onClick={() => onOpenStack(stack)}
>
{stack}
</button>
) : (
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
)
))}
</div>
)}
</Field>
</div>
</SheetSection>
@@ -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(
<ImageDetailsSheet image={baseImage} onClose={() => {}} 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(<ImageDetailsSheet image={null} onClose={() => {}} />);
expect(container.querySelector('[data-state="open"]')).toBeNull();
});
});