diff --git a/extensions/vscode-containers/src/test/tree/containers/ContainerProperties.test.ts b/extensions/vscode-containers/src/test/tree/containers/ContainerProperties.test.ts new file mode 100644 index 00000000..73120b9e --- /dev/null +++ b/extensions/vscode-containers/src/test/tree/containers/ContainerProperties.test.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ListContainersItem } from '@microsoft/vscode-container-client'; +import assert from 'assert'; +import { getComposeProjectGroup, NonComposeGroupName } from '../../../tree/containers/ContainerProperties'; + +function makeContainer(labels: { [key: string]: string } | undefined): ListContainersItem { + return { + labels: labels, + } as ListContainersItem; +} + +suite('(unit) ContainerProperties.getComposeProjectGroup', () => { + test('Groups compose containers by their compose project name', () => { + const container = makeContainer({ 'com.docker.compose.project': 'myproject' }); + assert.strictEqual(getComposeProjectGroup(container), 'myproject'); + }); + + test('Groups swarm stack containers by their stack namespace', () => { + const container = makeContainer({ 'com.docker.stack.namespace': 'mystack' }); + assert.strictEqual(getComposeProjectGroup(container), 'mystack'); + }); + + test('Prefers the compose project name when both labels are present', () => { + const container = makeContainer({ + 'com.docker.compose.project': 'myproject', + 'com.docker.stack.namespace': 'mystack', + }); + assert.strictEqual(getComposeProjectGroup(container), 'myproject'); + }); + + test('Returns the non-compose group name for standalone containers', () => { + assert.strictEqual(getComposeProjectGroup(makeContainer({})), NonComposeGroupName); + assert.strictEqual(getComposeProjectGroup(makeContainer(undefined)), NonComposeGroupName); + assert.strictEqual(getComposeProjectGroup(makeContainer({ 'some.other.label': 'value' })), NonComposeGroupName); + }); +}); diff --git a/extensions/vscode-containers/src/tree/LocalGroupTreeItemBase.ts b/extensions/vscode-containers/src/tree/LocalGroupTreeItemBase.ts index 61d69cf7..8a36f0f0 100644 --- a/extensions/vscode-containers/src/tree/LocalGroupTreeItemBase.ts +++ b/extensions/vscode-containers/src/tree/LocalGroupTreeItemBase.ts @@ -41,6 +41,10 @@ export abstract class LocalGroupTreeItemBase { @@ -26,13 +26,19 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase !!item.labels?.[composeProjectLabel]); + } + public get iconPath(): ThemeIcon { switch (this.parent.groupBySetting) { case 'ContainerId': diff --git a/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts b/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts index 2abbe2fe..6433b69b 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts @@ -20,7 +20,7 @@ export const containerProperties: ITreePropertyInfo[] = [ { property: 'Ports', exampleValue: '8080' }, { property: 'State', exampleValue: 'exited' }, { property: 'Status', exampleValue: 'Exited (0) 2 hours ago' }, - { property: 'Compose Project Name', description: l10n.t('Value used to associate containers launched by a compose up command') }, + { property: 'Compose Project Name', description: l10n.t('Value used to associate containers launched by a compose up command or a Docker Swarm stack') }, { property: 'Label', exampleValue: 'com.microsoft.created-by=visual-studio-code' }, ]; @@ -64,7 +64,7 @@ export function getContainerPropertyValue(item: ListContainersItem, property: Co // This normalizes things like "10 seconds" and "Less than a second" to "Less than a minute", meaning the refreshes don't happen constantly return item.status?.replace(/(\d+ seconds?)|(Less than a second)/i, l10n.t('Less than a minute')); case 'Compose Project Name': - return getLabelGroup(item, 'com.docker.compose.project', NonComposeGroupName); + return getComposeProjectGroup(item); case 'Image': return item.image.originalName; case 'Label': @@ -77,6 +77,21 @@ export function getContainerPropertyValue(item: ListContainersItem, property: Co export const NonComposeGroupName = l10n.t('Individual Containers'); export const NonLabelGroupName = l10n.t('Others'); +// The label Docker Compose adds to containers it creates, containing the compose project name +export const composeProjectLabel = 'com.docker.compose.project'; + +// The label Docker Swarm adds to containers it creates as part of a stack, containing the stack namespace (name) +export const swarmStackNamespaceLabel = 'com.docker.stack.namespace'; + +export function getComposeProjectGroup(container: ListContainersItem): string { + // Containers launched by `docker compose up` are grouped by their compose project name. + // Containers launched by `docker stack deploy` (Docker Swarm) are grouped by their stack namespace. + // Either way, this gives a meaningful group name for orchestrated containers, falling back to the + // non-compose group name for standalone containers. + return getLabelGroup(container, composeProjectLabel, '') + || getLabelGroup(container, swarmStackNamespaceLabel, NonComposeGroupName); +} + export function getLabelGroup(container: ListContainersItem, label: string | undefined, defaultGroupName: string): string { if (!label) { return defaultGroupName;