mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user