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),
};