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 }}
+