From e82a4bd9b37694dde64a2091fa7f256e5bea40e2 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:11:10 -0400 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20feat(dockercompose):=20opt-in?= =?UTF-8?q?=20mount-prefix=20fallback=20for=20compose=20path=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a container's resolved compose file path differs from the configured compose file only by a mount prefix, match on the trailing / tail instead of skipping the container. Gated behind an opt-in mountPrefixFallback option (DD_ACTION_DOCKERCOMPOSE__MOUNT_PREFIX_FALLBACK), default off, because tail matching cannot tell apart stacks that share a project-dir name across environments. Refs: #365 --- CHANGELOG.md | 4 + .../dockercompose/Dockercompose.test.ts | 74 +++++++++++++++++++ .../providers/dockercompose/Dockercompose.ts | 21 ++++++ 3 files changed, 99 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214e088f6..0efbdb0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Optional mount-prefix fallback for Docker Compose path matching.** When a watched container's resolved compose file path differs from the trigger's configured compose file only by a mount prefix (common with Portainer and bind-mounted compose files), drydock can now match on the trailing `/` tail instead of skipping the container. Off by default — enable it per trigger with `DD_ACTION_DOCKERCOMPOSE__MOUNT_PREFIX_FALLBACK=true`. It stays opt-in because tail matching cannot distinguish two stacks that share a project-directory name across environments (e.g. `/prod/myapp` vs `/staging/myapp`). (#365) + ## [1.5.1-rc.3] — 2026-06-28 ### Added diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 2addd3f83..3d2e32f32 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -3143,6 +3143,80 @@ describe('Dockercompose Trigger', () => { ); }); + test('triggerBatch should use configured path when label path differs only by mount prefix (issue #365)', async () => { + // Portainer bind-mounts /drydock/mystack -> /data/compose/mystack inside the container. + // The label reflects the in-container path; Drydock's configured path is the host path. + trigger.configuration.file = '/drydock/mystack/docker-compose.yml'; + trigger.configuration.mountPrefixFallback = true; + fs.access.mockResolvedValue(undefined); + + const container = { + name: 'portainer-app', + watcher: 'local', + labels: { 'dd.compose.file': '/data/compose/mystack/docker-compose.yml' }, + }; + + const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue(); + + await trigger.triggerBatch([container]); + + // Should warn about the mount prefix mismatch (not the "do not match" hard skip) + expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('mount prefix')); + expect(mockLog.warn).not.toHaveBeenCalledWith( + expect.stringContaining('do not match configured file'), + ); + // processComposeFile must be called with the Drydock-accessible (configured) path + expect(processComposeFileSpy).toHaveBeenCalledWith('/drydock/mystack/docker-compose.yml', [ + container, + ]); + }); + + test('triggerBatch should hard-skip when tail segments do not match (issue #365 no fallback)', async () => { + // stackA vs stackB — different project-dir segment, no tail match even with flag on + trigger.configuration.file = '/drydock/stackA/docker-compose.yml'; + trigger.configuration.mountPrefixFallback = true; + fs.access.mockResolvedValue(undefined); + + const container = { + name: 'unrelated-app', + watcher: 'local', + labels: { 'dd.compose.file': '/data/compose/stackB/docker-compose.yml' }, + }; + + const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue(); + + await trigger.triggerBatch([container]); + + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining('do not match configured file'), + ); + expect(mockLog.warn).not.toHaveBeenCalledWith(expect.stringContaining('mount prefix')); + expect(processComposeFileSpy).not.toHaveBeenCalled(); + }); + + test('triggerBatch should hard-skip when tail matches but mountPrefixFallback is off (default)', async () => { + // Same tail (mystack/docker-compose.yml) but flag is NOT set — must hard-skip, not use fallback. + trigger.configuration.file = '/drydock/mystack/docker-compose.yml'; + // mountPrefixFallback is absent from the config (default false) + fs.access.mockResolvedValue(undefined); + + const container = { + name: 'portainer-app', + watcher: 'local', + labels: { 'dd.compose.file': '/data/compose/mystack/docker-compose.yml' }, + }; + + const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue(); + + await trigger.triggerBatch([container]); + + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining('do not match configured file'), + ); + expect(mockLog.warn).not.toHaveBeenCalledWith(expect.stringContaining('mount prefix')); + expect(processComposeFileSpy).not.toHaveBeenCalled(); + }); + test('triggerBatch should warn when no containers matched any compose file', async () => { trigger.configuration.file = undefined; diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index aae009335..97972dda2 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -49,6 +49,7 @@ interface DockercomposeTriggerConfiguration extends DockerTriggerConfiguration { reconciliationMode: 'warn' | 'block' | 'off'; digestPinning: boolean; composeFileOnce: boolean; + mountPrefixFallback: boolean; } interface DockerApiLike { @@ -416,6 +417,7 @@ class Dockercompose extends Docker { reconciliationMode: this.joi.string().valid('warn', 'block', 'off').default('warn'), digestPinning: this.joi.boolean().default(false), composeFileOnce: this.joi.boolean().default(false), + mountPrefixFallback: this.joi.boolean().default(false), }) .rename('composefilelabel', 'composeFileLabel', { ignoreUndefined: true, @@ -432,6 +434,10 @@ class Dockercompose extends Docker { .rename('composefileonce', 'composeFileOnce', { ignoreUndefined: true, override: true, + }) + .rename('mountprefixfallback', 'mountPrefixFallback', { + ignoreUndefined: true, + override: true, }); } @@ -1434,6 +1440,21 @@ class Dockercompose extends Docker { } if (configuredComposeFilePath && !composeFiles.includes(configuredComposeFilePath)) { + if (this.configuration.mountPrefixFallback) { + const configuredTail = path.join( + path.basename(path.dirname(configuredComposeFilePath)), + path.basename(configuredComposeFilePath), + ); + const tailMatch = composeFiles.some( + (f) => path.join(path.basename(path.dirname(f)), path.basename(f)) === configuredTail, + ); + if (tailMatch) { + this.log.warn( + `Container ${container.name} compose file path differs by mount prefix; using configured path ${configuredComposeFilePath} instead of label path(s) ${composeFiles.join(', ')} (issue #365 fallback)`, + ); + return [configuredComposeFilePath]; + } + } this.log.warn( `Skip container ${container.name} because compose files ${composeFiles.join(', ')} do not match configured file ${configuredComposeFilePath}`, ); From 512d09d043fa9ece1127b183ee663051459ef876 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:11:31 -0400 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20feat(triggers):=20add=20$curr?= =?UTF-8?q?entReleaseNotes=20template=20variable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger templates can now reference $currentReleaseNotes to include the running version's release notes alongside the existing target-version notes. Refs: #295 --- CHANGELOG.md | 2 ++ .../trigger-expression-parser.test.ts | 19 +++++++++++++++++++ .../providers/trigger-expression-parser.ts | 1 + 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0efbdb0db..df54777ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Optional mount-prefix fallback for Docker Compose path matching.** When a watched container's resolved compose file path differs from the trigger's configured compose file only by a mount prefix (common with Portainer and bind-mounted compose files), drydock can now match on the trailing `/` tail instead of skipping the container. Off by default — enable it per trigger with `DD_ACTION_DOCKERCOMPOSE__MOUNT_PREFIX_FALLBACK=true`. It stays opt-in because tail matching cannot distinguish two stacks that share a project-directory name across environments (e.g. `/prod/myapp` vs `/staging/myapp`). (#365) +- **`$currentReleaseNotes` trigger template variable.** Trigger templates (notification bodies, command arguments, and the like) can now reference `$currentReleaseNotes` to include the release notes for the container's currently running version, alongside the existing variable for the update target's notes. (#295) + ## [1.5.1-rc.3] — 2026-06-28 ### Added diff --git a/app/triggers/providers/trigger-expression-parser.test.ts b/app/triggers/providers/trigger-expression-parser.test.ts index d1c38303e..a69c8e515 100644 --- a/app/triggers/providers/trigger-expression-parser.test.ts +++ b/app/triggers/providers/trigger-expression-parser.test.ts @@ -113,6 +113,25 @@ describe('trigger-expression-parser', () => { expect(output).toBe('Release title'); }); + test('renderSimple should expose currentReleaseNotes template variable', () => { + const output = renderSimple('${currentReleaseNotes.title}', { + ...baseContainer, + currentReleaseNotes: { + title: 'Current title', + body: '', + url: '', + publishedAt: '', + provider: 'github', + }, + } as any); + expect(output).toBe('Current title'); + }); + + test('renderSimple should return empty string for currentReleaseNotes when not set', () => { + const output = renderSimple('${currentReleaseNotes}', baseContainer as any); + expect(output).toBe(''); + }); + test('renderSimple should expose currentTag variable from container image tag', () => { const output = renderSimple('Tag is ${currentTag}', { ...baseContainer, diff --git a/app/triggers/providers/trigger-expression-parser.ts b/app/triggers/providers/trigger-expression-parser.ts index e11e360f2..82334cf3d 100644 --- a/app/triggers/providers/trigger-expression-parser.ts +++ b/app/triggers/providers/trigger-expression-parser.ts @@ -373,6 +373,7 @@ export function renderSimple(template: string, container: Container): string { container, event: event && typeof event === 'object' ? event : {}, releaseNotes: container.result?.releaseNotes, + currentReleaseNotes: container.currentReleaseNotes, suggestedTag: container.result?.suggestedTag ?? container.result?.tag ?? '', currentTag: container.image?.tag?.value ?? '', isDigestUpdate: isDigest, From bc4d74badb5cef68e4d49f478f011545a3546ae4 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:12:19 -0400 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20feat(containers):=20surface?= =?UTF-8?q?=20software=20version=20and=20uptime=20in=20container=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✨ feat(containers): read the image software version (org.opencontainers.image.version OCI label, with a container-inspect fallback) into image.softwareVersion and show it in the side and full-page detail panels (#209) - ✨ feat(containers): show container uptime from State.StartedAt in the detail panels and as an opt-in Uptime table column, with a live-updating display - 🐛 fix(model): tolerate unknown container fields (Joi allowUnknown) so a store written by a newer version stays readable after a downgrade - ✅ test: cover the new model fields, watcher resolution, uptime formatter, useNow gating, and the column-visibility and preferences-migration paths Refs: #209 --- CHANGELOG.md | 8 + app/model/container.test.ts | 84 ++++++++ app/model/container.ts | 6 +- .../docker/Docker.containers.details.test.ts | 87 ++++++++ .../docker-image-details-orchestration.ts | 25 +++ .../providers/docker/runtime-details.test.ts | 111 ++++++++++ .../providers/docker/runtime-details.ts | 14 ++ .../ContainerFullPageTabContent.vue | 25 +++ .../containers/ContainerSideTabContent.vue | 7 + .../containers/ContainersGroupedViews.vue | 12 ++ ui/src/composables/useColumnVisibility.ts | 11 + ui/src/composables/useNow.ts | 48 +++++ ui/src/locales/en/containerComponents.json | 5 +- ui/src/locales/en/containersView.json | 3 +- ui/src/preferences/migrate.ts | 7 +- ui/src/preferences/schema.ts | 2 + ui/src/types/container.d.ts | 2 + ui/src/utils/container-mapper.ts | 13 ++ ui/src/utils/uptime.ts | 49 +++++ .../ContainerSideTabContent.spec.ts | 22 ++ .../ContainerFullPageTabContent.spec.ts | 47 ++++ .../containers/ContainersGroupedViews.spec.ts | 75 +++++++ .../composables/useColumnVisibility.spec.ts | 89 ++++++-- ui/tests/composables/useNow.spec.ts | 204 ++++++++++++++++++ ui/tests/utils/container-mapper.spec.ts | 114 ++++++++++ ui/tests/utils/uptime.spec.ts | 121 +++++++++++ 26 files changed, 1173 insertions(+), 18 deletions(-) create mode 100644 ui/src/composables/useNow.ts create mode 100644 ui/src/utils/uptime.ts create mode 100644 ui/tests/composables/useNow.spec.ts create mode 100644 ui/tests/utils/uptime.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index df54777ba..fdece96b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`$currentReleaseNotes` trigger template variable.** Trigger templates (notification bodies, command arguments, and the like) can now reference `$currentReleaseNotes` to include the release notes for the container's currently running version, alongside the existing variable for the update target's notes. (#295) +- **Container software version in the detail panels.** Drydock now surfaces the application version baked into an image — read from the `org.opencontainers.image.version` OCI label, falling back to the running container's inspect metadata — as `image.softwareVersion`. It appears in the container side panel and the full-page detail view. (#209) + +- **Container uptime.** The side panel and full-page detail view now show how long a container has been running (from the Docker `State.StartedAt` timestamp), and a new opt-in **Uptime** column can be enabled in the containers table via the column picker. The value updates live and falls back to an em-dash when the start time is unknown. + +### Changed + +- **Container validation now tolerates fields written by newer drydock versions.** The store validator no longer rejects unknown keys, so a `dd.json` written by a newer release stays readable after a downgrade. Note: this protects downgrades from v1.5.1 onward — rolling back from v1.5.1 to v1.5.0 (which predates this change) still requires removing the new `details.startedAt` and `image.softwareVersion` fields from `dd.json`, since v1.5.0 rejects them. + ## [1.5.1-rc.3] — 2026-06-28 ### Added diff --git a/app/model/container.test.ts b/app/model/container.test.ts index 6ac6dc431..1c731d1c2 100644 --- a/app/model/container.test.ts +++ b/app/model/container.test.ts @@ -904,12 +904,96 @@ test('model should accept details with empty env values', async () => { ]); }); +test('model should accept details with startedAt iso date', async () => { + const containerValidated = container.validate({ + id: 'container-startedat-test', + name: 'startedat-test', + watcher: 'test', + image: { + id: 'image-startedat-test', + registry: { name: 'hub', url: 'https://hub' }, + name: 'organization/image', + tag: { value: '1.0.0', semver: true }, + digest: { watch: false }, + architecture: 'arch', + os: 'os', + }, + details: { + ports: [], + volumes: [], + env: [], + startedAt: '2024-06-01T12:00:00.000Z', + }, + }); + expect(containerValidated.details.startedAt).toBe('2024-06-01T12:00:00.000Z'); +}); + +test('model should accept details without startedAt', async () => { + const containerValidated = container.validate({ + id: 'container-no-startedat', + name: 'no-startedat', + watcher: 'test', + image: { + id: 'image-no-startedat', + registry: { name: 'hub', url: 'https://hub' }, + name: 'organization/image', + tag: { value: '1.0.0', semver: true }, + digest: { watch: false }, + architecture: 'arch', + os: 'os', + }, + details: { + ports: [], + volumes: [], + env: [], + }, + }); + expect(containerValidated.details.startedAt).toBeUndefined(); +}); + +test('model should reject details with non-ISO startedAt', async () => { + expect(() => { + container.validate({ + id: 'container-bad-startedat', + name: 'bad-startedat', + watcher: 'test', + image: { + id: 'image-bad-startedat', + registry: { name: 'hub', url: 'https://hub' }, + name: 'organization/image', + tag: { value: '1.0.0', semver: true }, + digest: { watch: false }, + architecture: 'arch', + os: 'os', + }, + details: { + ports: [], + volumes: [], + env: [], + startedAt: 'not-a-date', + }, + }); + }).toThrow('"details.startedAt" must be in iso format'); +}); + test('model should reject empty error message', async () => { expect(() => { container.validate(createContainerWithError('')); }).toThrow('ValidationError: "error.message" is not allowed to be empty'); }); +test('validate should allow and preserve unknown future fields (forward compatibility)', () => { + const input = { + ...createValidContainer(), + someFutureField: 'x', + }; + let result: unknown; + expect(() => { + result = container.validate(input); + }).not.toThrow(); + expect((result as Record).someFutureField).toBe('x'); +}); + test.each([ ['service-old-0123456789', true], ['service-old-01234567890', true], diff --git a/app/model/container.ts b/app/model/container.ts index 758123e1b..5f99c99c8 100644 --- a/app/model/container.ts +++ b/app/model/container.ts @@ -70,6 +70,7 @@ export interface ContainerImage { os: string; variant?: string; created?: string; + softwareVersion?: string; } export interface ContainerResult { @@ -136,6 +137,7 @@ export interface ContainerRuntimeDetails { ports: string[]; volumes: string[]; env: ContainerRuntimeEnv[]; + startedAt?: string; } export interface ContainerUpdateOperationState { @@ -328,6 +330,7 @@ const schema = joi.object({ os: joi.string().min(1).required(), variant: joi.string(), created: joi.string().isoDate(), + softwareVersion: joi.string(), }) .required(), result: joi.object({ @@ -385,6 +388,7 @@ const schema = joi.object({ }), ) .required(), + startedAt: joi.string().isoDate().optional(), }), }); @@ -811,7 +815,7 @@ function addResultChangedFunction(container: Container) { * @returns {*} */ export function validate(container: unknown): Container { - const validation = schema.validate(container); + const validation = schema.validate(container, { allowUnknown: true }); if (validation.error) { throw new Error(`Error when validating container properties ${validation.error}`); } diff --git a/app/watchers/providers/docker/Docker.containers.details.test.ts b/app/watchers/providers/docker/Docker.containers.details.test.ts index dc4b5f569..1fd42dc01 100644 --- a/app/watchers/providers/docker/Docker.containers.details.test.ts +++ b/app/watchers/providers/docker/Docker.containers.details.test.ts @@ -1373,5 +1373,92 @@ describe('Docker Watcher', () => { // Should have fallen through to use RepoTags expect(result.image.tag.value).toBe('stable'); }); + + describe('softwareVersion from OCI label', () => { + test('should populate softwareVersion from org.opencontainers.image.version label on image inspect', async () => { + const container = await setupContainerDetailTest(docker, { + container: { Image: 'nginx:latest', Names: ['/nginx'] }, + imageDetails: { + Config: { + Labels: { 'org.opencontainers.image.version': '1.25.5' }, + }, + }, + }); + + const result = await docker.addImageDetailsToContainer(container); + + expect(result.image.softwareVersion).toBe('1.25.5'); + }); + + test('should leave softwareVersion undefined when OCI label is absent', async () => { + const container = await setupContainerDetailTest(docker, { + container: { Image: 'nginx:latest', Names: ['/nginx'] }, + imageDetails: { + Config: { Labels: {} }, + }, + }); + + const result = await docker.addImageDetailsToContainer(container); + + expect(result.image.softwareVersion).toBeUndefined(); + }); + + test('should normalize empty OCI label string to undefined softwareVersion', async () => { + const container = await setupContainerDetailTest(docker, { + container: { Image: 'nginx:latest', Names: ['/nginx'] }, + imageDetails: { + Config: { + Labels: { 'org.opencontainers.image.version': '' }, + }, + }, + }); + + const result = await docker.addImageDetailsToContainer(container); + + expect(result.image.softwareVersion).toBeUndefined(); + }); + + test('should fall back to container inspect when image inspect has no OCI version label', async () => { + const container = await setupContainerDetailTest(docker, { + container: { Image: 'nginx:latest', Names: ['/nginx'] }, + imageDetails: { + Config: { Labels: {} }, + }, + }); + mockContainer.inspect.mockResolvedValue({ + Config: { + Labels: { 'org.opencontainers.image.version': '1.25.5-from-container' }, + }, + }); + + const result = await docker.addImageDetailsToContainer(container); + + expect(result.image.softwareVersion).toBe('1.25.5-from-container'); + }); + + test('should set softwareVersion and still override tag when dd.inspect.tag.path is configured', async () => { + const container = await setupContainerDetailTest(docker, { + container: { + Image: 'nginx:stable', + Names: ['/nginx'], + Labels: { 'dd.inspect.tag.path': 'Config/Labels/org.opencontainers.image.version' }, + }, + imageDetails: { + Config: { + Labels: { 'org.opencontainers.image.version': '1.25.5' }, + }, + }, + semverValue: { major: 1, minor: 25, patch: 5, version: '1.25.5' }, + }); + hMockTag.transform.mockImplementation((_transform, value) => value); + + const result = await docker.addImageDetailsToContainer(container); + + // tag override from dd.inspect.tag.path is preserved (dual-write, not removed) + expect(result.image.tag.value).toBe('1.25.5'); + // softwareVersion populated from same image label + expect(result.image.softwareVersion).toBe('1.25.5'); + }); + }); }); }); diff --git a/app/watchers/providers/docker/docker-image-details-orchestration.ts b/app/watchers/providers/docker/docker-image-details-orchestration.ts index 7b14d48f0..77e89797a 100644 --- a/app/watchers/providers/docker/docker-image-details-orchestration.ts +++ b/app/watchers/providers/docker/docker-image-details-orchestration.ts @@ -18,6 +18,7 @@ import { canonicalizeContainerName, getContainerDisplayName, getContainerName, + getInspectValueByPath, getRepoDigest, isDigestToWatch, type ResolvedImgset, @@ -583,6 +584,29 @@ function warnWhenUntrackableImage( } } +const OCI_VERSION_LABEL = 'org.opencontainers.image.version'; +const OCI_VERSION_INSPECT_PATH = `Config/Labels/${OCI_VERSION_LABEL}`; + +/** + * Extract the OCI software version label from image inspect labels, with + * container inspect as a fallback. Empty strings normalize to undefined. + */ +function resolveSoftwareVersion( + image: DockerImageInspectPayload, + containerInspect: DockerContainerInspectPayload | undefined, +): string | undefined { + const imageVersion = image.Config?.Labels?.[OCI_VERSION_LABEL]; + const rawVersion = + imageVersion !== undefined + ? imageVersion + : (getInspectValueByPath(containerInspect, OCI_VERSION_INSPECT_PATH) as string | undefined); + if (typeof rawVersion !== 'string') { + return undefined; + } + const trimmed = rawVersion.trim(); + return trimmed !== '' ? trimmed : undefined; +} + function removeStaleContainerEntriesWithSameName( watcher: DockerImageDetailsWatcher, containerToReturn: Container, @@ -735,6 +759,7 @@ export async function addImageDetailsToContainerOrchestration( os: image.Os, variant: image.Variant, created: image.Created, + softwareVersion: resolveSoftwareVersion(image, containerInspect), }, labels: containerLabels, sourceRepo: detectSourceRepoFromImageMetadata({ diff --git a/app/watchers/providers/docker/runtime-details.test.ts b/app/watchers/providers/docker/runtime-details.test.ts index b384c70fa..00ef554f1 100644 --- a/app/watchers/providers/docker/runtime-details.test.ts +++ b/app/watchers/providers/docker/runtime-details.test.ts @@ -274,4 +274,115 @@ describe('docker runtime details module', () => { env: [], }); }); + + test('extracts startedAt from inspect State when present and valid', () => { + const details = getRuntimeDetailsFromInspect({ + State: { StartedAt: '2024-06-01T12:00:00Z' }, + } as any); + expect(details.startedAt).toBe('2024-06-01T12:00:00Z'); + }); + + test('omits startedAt when State is missing', () => { + const details = getRuntimeDetailsFromInspect({} as any); + expect(details.startedAt).toBeUndefined(); + }); + + test('omits startedAt when State.StartedAt is the Docker zero-time sentinel', () => { + const details = getRuntimeDetailsFromInspect({ + State: { StartedAt: '0001-01-01T00:00:00Z' }, + } as any); + expect(details.startedAt).toBeUndefined(); + }); + + test('omits startedAt when State.StartedAt starts with 0001-', () => { + const details = getRuntimeDetailsFromInspect({ + State: { StartedAt: '0001-01-01T00:00:00.000Z' }, + } as any); + expect(details.startedAt).toBeUndefined(); + }); + + test('omits startedAt when State.StartedAt is not a string', () => { + const details = getRuntimeDetailsFromInspect({ + State: { StartedAt: null }, + } as any); + expect(details.startedAt).toBeUndefined(); + }); + + test('omits startedAt when State.StartedAt is an empty string', () => { + const details = getRuntimeDetailsFromInspect({ + State: { StartedAt: '' }, + } as any); + expect(details.startedAt).toBeUndefined(); + }); + + test('merge prefers startedAt from preferred details', () => { + const merged = mergeRuntimeDetails( + { ports: [], volumes: [], env: [], startedAt: '2024-06-01T12:00:00Z' }, + { ports: [], volumes: [], env: [], startedAt: '2023-01-01T00:00:00Z' }, + ); + expect(merged.startedAt).toBe('2024-06-01T12:00:00Z'); + }); + + test('merge falls back to fallback startedAt when preferred has none', () => { + const merged = mergeRuntimeDetails( + { ports: [], volumes: [], env: [] }, + { ports: [], volumes: [], env: [], startedAt: '2023-01-01T00:00:00Z' }, + ); + expect(merged.startedAt).toBe('2023-01-01T00:00:00Z'); + }); + + test('merge yields undefined startedAt when neither preferred nor fallback has one', () => { + const merged = mergeRuntimeDetails( + { ports: [], volumes: [], env: [] }, + { ports: [], volumes: [], env: [] }, + ); + expect(merged.startedAt).toBeUndefined(); + }); + + test('areRuntimeDetailsEqual returns false when startedAt differs', () => { + expect( + areRuntimeDetailsEqual( + { ports: [], volumes: [], env: [], startedAt: '2024-06-01T12:00:00Z' }, + { ports: [], volumes: [], env: [], startedAt: '2024-06-02T12:00:00Z' }, + ), + ).toBe(false); + }); + + test('areRuntimeDetailsEqual returns false when one has startedAt and the other does not', () => { + expect( + areRuntimeDetailsEqual( + { ports: [], volumes: [], env: [], startedAt: '2024-06-01T12:00:00Z' }, + { ports: [], volumes: [], env: [] }, + ), + ).toBe(false); + }); + + test('areRuntimeDetailsEqual returns true when both have the same startedAt', () => { + expect( + areRuntimeDetailsEqual( + { ports: [], volumes: [], env: [], startedAt: '2024-06-01T12:00:00Z' }, + { ports: [], volumes: [], env: [], startedAt: '2024-06-01T12:00:00Z' }, + ), + ).toBe(true); + }); + + test('normalizeRuntimeDetails carries startedAt through', () => { + const normalized = normalizeRuntimeDetails({ + ports: [], + volumes: [], + env: [], + startedAt: '2024-06-01T12:00:00Z', + }); + expect(normalized.startedAt).toBe('2024-06-01T12:00:00Z'); + }); + + test('normalizeRuntimeDetails omits startedAt when not a non-empty string', () => { + expect( + normalizeRuntimeDetails({ ports: [], volumes: [], env: [], startedAt: '' }).startedAt, + ).toBeUndefined(); + expect( + normalizeRuntimeDetails({ ports: [], volumes: [], env: [], startedAt: null }).startedAt, + ).toBeUndefined(); + expect(normalizeRuntimeDetails({ ports: [], volumes: [], env: [] }).startedAt).toBeUndefined(); + }); }); diff --git a/app/watchers/providers/docker/runtime-details.ts b/app/watchers/providers/docker/runtime-details.ts index e8aea2813..0dbe1b728 100644 --- a/app/watchers/providers/docker/runtime-details.ts +++ b/app/watchers/providers/docker/runtime-details.ts @@ -60,10 +60,14 @@ export function normalizeRuntimeDetails(details: unknown): ContainerRuntimeDetai if (!runtimeDetails) { return getEmptyRuntimeDetails(); } + const startedAt = isNonEmptyString(runtimeDetails.startedAt) + ? runtimeDetails.startedAt + : undefined; return { ports: normalizeRuntimeStringList(runtimeDetails.ports), volumes: normalizeRuntimeStringList(runtimeDetails.volumes), env: normalizeRuntimeEnvList(runtimeDetails.env), + ...(startedAt !== undefined ? { startedAt } : {}), }; } @@ -74,6 +78,9 @@ export function areRuntimeDetailsEqual( const normalizedDetailsA = normalizeRuntimeDetails(detailsA); const normalizedDetailsB = normalizeRuntimeDetails(detailsB); + if (normalizedDetailsA.startedAt !== normalizedDetailsB.startedAt) { + return false; + } if (normalizedDetailsA.ports.length !== normalizedDetailsB.ports.length) { return false; } @@ -248,11 +255,16 @@ export function getRuntimeDetailsFromInspect(containerInspect: unknown): Contain const inspect = asUnknownRecord(containerInspect); const networkSettings = asUnknownRecord(inspect?.NetworkSettings); const config = asUnknownRecord(inspect?.Config); + const state = asUnknownRecord(inspect?.State); + const rawStartedAt = state?.StartedAt; + const startedAt = + isNonEmptyString(rawStartedAt) && !rawStartedAt.startsWith('0001-') ? rawStartedAt : undefined; return { ports: formatContainerPortsFromInspect(networkSettings?.Ports), volumes: formatContainerVolumes(inspect?.Mounts), env: formatContainerEnv(config?.Env), + ...(startedAt !== undefined ? { startedAt } : {}), }; } @@ -269,10 +281,12 @@ export function mergeRuntimeDetails( preferredDetails: ContainerRuntimeDetails, fallbackDetails: ContainerRuntimeDetails, ): ContainerRuntimeDetails { + const startedAt = preferredDetails.startedAt ?? fallbackDetails.startedAt; return { ports: preferredDetails.ports.length > 0 ? preferredDetails.ports : fallbackDetails.ports, volumes: preferredDetails.volumes.length > 0 ? preferredDetails.volumes : fallbackDetails.volumes, env: preferredDetails.env.length > 0 ? preferredDetails.env : fallbackDetails.env, + ...(startedAt !== undefined ? { startedAt } : {}), }; } diff --git a/ui/src/components/containers/ContainerFullPageTabContent.vue b/ui/src/components/containers/ContainerFullPageTabContent.vue index 512a4a9d9..95eaa92d3 100644 --- a/ui/src/components/containers/ContainerFullPageTabContent.vue +++ b/ui/src/components/containers/ContainerFullPageTabContent.vue @@ -21,6 +21,8 @@ import { getPrimaryHardBlocker } from '../../utils/update-eligibility'; import { useContainersViewTemplateContext } from './containersViewTemplateContext'; import { formatShortDigest } from '../../utils/digest-format'; import { imageAge } from '../../utils/audit-helpers'; +import { useNow } from '../../composables/useNow'; +import { formatUptimeFromIso } from '../../utils/uptime'; interface RevealEnvResponse { env?: Array<{ key: string; value: string }>; @@ -158,6 +160,12 @@ const { updateKindColor, } = useContainersViewTemplateContext(); +const nowMs = useNow(1_000, () => !!selectedContainer.value?.details?.startedAt); + +const uptimeString = computed(() => + formatUptimeFromIso(selectedContainer.value?.details?.startedAt, nowMs.value), +); + const { t } = useI18n(); function isActionInProgress(container: { id?: unknown; name?: unknown }) { @@ -272,6 +280,13 @@ function getUpdateKindLabel(kind: Container['updateKind']) { {{ selectedContainer.isDigestPinned && selectedContainer.currentDigest ? formatShortDigest(selectedContainer.currentDigest) : selectedContainer.currentTag }} +
+ {{ t('containerComponents.fullPageOverview.softwareVersion') }} + {{ selectedContainer.softwareVersion }} +
@@ -426,6 +441,16 @@ function getUpdateKindLabel(kind: Container['updateKind']) { {{ runtimeOriginLabel(selectedRuntimeOrigins.cmd) }}
+
+ + {{ t('containerComponents.fullPageOverview.uptime') }} + + + {{ uptimeString }} + +
{{ formatShortDigest(selectedContainer.newDigest) }}
+
+ {{ t('containerComponents.fullPageOverview.softwareVersion') }} + {{ selectedContainer.softwareVersion }} +
visibleColumns.value.has('uptime')); const { t, te } = useI18n(); const { batches, clearBatch, getBatch, incrementSucceeded, incrementFailed } = useUpdateBatches(); @@ -701,6 +706,7 @@ onScopeDispose(() => {
+
{{ c.softwareVersion }}
+ + -
{{ c.softwareVersion }}
+ + +