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
+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();
});
});