diff --git a/CHANGELOG.md b/CHANGELOG.md index 214e088f6..207ae9e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1-rc.4] — 2026-06-29 + +### 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) + +- **`$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 and a new Version column in the containers table.** 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, the full-page detail view, and a new **Version** column in the containers table. The existing **Tag** column (column key `version`, preserved so saved column preferences keep working) continues to show the image tag; the new **Version** column shows `image.softwareVersion`, falling back to the tag when no software version is available. `dd.inspect.tag.path` now dual-writes the extracted value into `image.softwareVersion` as well as overwriting the image tag, so the Version column is populated for inspect-path containers with no label change needed. The Version column is visible by default for new installs; existing users have it inserted into their saved column list automatically on first load after upgrading. (#209) + +- **`dd.inspect.tag.version-only` opt-in label.** When `dd.inspect.tag.path` is set, the extracted value normally overwrites the image tag (enabling update detection against the semver embedded in the running container). Setting `dd.inspect.tag.version-only=true` routes the extracted value to `image.softwareVersion` only, leaving the real image tag intact for update detection. This is useful when the inspect path carries a displayable application version that differs in format from the registry tag — the Version column shows it without disrupting how drydock matches updates. The default (tag overwrite) is unchanged when the label is absent. (#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. + +### Fixed + +- **Completed i18n coverage for the last untranslated UI surfaces.** A code-level audit found several strings that still rendered in English for non-English users; they now resolve through the translation catalog: the trigger status badge (`active`/`inactive`), the running/writes-compose `yes`/`no` preview values, the "container actions disabled by server configuration" tooltip, the update-maturity "Available for N days" tooltip (the translate function is now threaded through the container mapper, which previously left the existing catalog keys unused), the grouped "Update All" success toast (which appended a raw English `in ` — it now interpolates the group name through a translatable key), the security-view severity tooltips (`CRITICAL`/`HIGH`/`MEDIUM`/`LOW`), the backup operation `unknown` fallback label, and the search-bar hint footer connectors. The new English catalog keys ship now; the 16 community locales fill in through the normal Crowdin sync after release. (#329) + +### Security + +- **Base image refreshed to clear 24 container-scan CVEs.** Bumped the pinned `node:24-alpine` base from a stale digest (Node 24.16.0, Alpine 3.21) to the current digest (Node 24.18.0, Alpine 3.24) and added `libexpat` to the targeted `apk upgrade` set. This resolves all 11 Node binary CVEs reported by the image scan — including the one critical (CVE-2026-48930) and four high — plus 13 medium `libexpat` CVEs (now `2.8.2-r0`). A rebuild + rescan confirms zero critical/high/Node/libexpat findings remain. The three `busybox`/`ssl_client` findings (CVE-2025-60876, medium) have no upstream fix in Alpine yet and are tracked for a later base bump. All previously pinned Alpine package versions still resolve on 3.24, so the build is otherwise unchanged. + ## [1.5.1-rc.3] — 2026-06-28 ### Added diff --git a/Dockerfile b/Dockerfile index 29b7bf49c..dcd4dceb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # checkov:skip=CKV_DOCKER_3: entrypoint uses su-exec for runtime privilege drop # Common Stage -FROM node:24-alpine@sha256:21f403ab171f2dc89bad4dd69d7721bfd15f084ccb46cdd225f31f2bc59b5c9a AS base +FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS base WORKDIR /home/node/app LABEL maintainer="CodesWhat" @@ -34,7 +34,7 @@ RUN apk add --no-cache \ tzdata=2026b-r0 \ && apk add --no-cache cosign=3.0.6-r1 \ && apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing trivy \ - && apk upgrade --no-cache zlib libcrypto3 libssl3 \ + && apk upgrade --no-cache zlib libcrypto3 libssl3 libexpat \ && mkdir /store && chown node:node /store # Build stage for healthcheck binary (~65KB static binary) diff --git a/README.md b/README.md index baa6a42e7..4b838d6ae 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ High-level themes only — see [CHANGELOG.md](CHANGELOG.md) for per-release deta | **v1.3.x** ✅ | Security & Stability | Trivy scanning, Update Bouncer, SBOM, 7 new registries, 4 new triggers, re2js regex engine | | **v1.4.x** ✅ | UI Modernization & Hardening | Tailwind 4 + custom components, 6 themes, Cmd/K palette, OpenAPI 3.1, compose-native YAML updates, dual-slot scanning, OIDC hardening | | **v1.5.0** ✅ | Observability & i18n | trigger taxonomy split (`DD_ACTION_*`/`DD_NOTIFICATION_*`), WebSocket log viewer, dashboard customization, resource monitoring, notification outbox + DLQ, security scan digest, 17 locales, SSE Last-Event-ID replay, edge agent dial-out with Ed25519 auth (experimental, `DD_EXPERIMENTAL_PORTWING=true`) | -| **v1.5.1** | Security & Maintenance | GCR/GAR pull-auth fix, registry TLS completion (M-2), hook env-var injection hardening, `DD_SESSION_SECRET__FILE` support, debug-dump credential redaction, secret-file permission check, maturity gate deadlock fix, full UI translatability + community translations, maintenance-window auto-apply gate | +| **v1.5.1** | Security & Maintenance | GCR/GAR pull-auth fix, registry TLS completion (M-2), hook env-var injection hardening, `DD_SESSION_SECRET__FILE` support, debug-dump credential redaction, secret-file permission check, maturity gate deadlock fix, full UI translatability + community translations, maintenance-window auto-apply gate, container uptime display, Tag/Version column split surfacing software version (OCI label, with `dd.inspect.tag.path` dual-write + opt-in `dd.inspect.tag.version-only` routing), opt-in compose mount-prefix matching, `$currentReleaseNotes` template var | | **v1.6.0** | Scanner Decoupling & Release Intel | Backend-based scanner + Grype, notification templates, declarative update policy, table-only UI, SBOM off-heap storage | | **v1.7.0** | Smart Updates & UX | Dependency-aware ordering, image prune, static image monitoring, keyboard shortcuts, PWA | | **v1.8.0** | Fleet Management & Live Config | YAML config, live UI config, volume browser, parallel updates, SQLite store migration | 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/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}`, ); 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, diff --git a/app/watchers/providers/docker/Docker.containers.details.test.ts b/app/watchers/providers/docker/Docker.containers.details.test.ts index dc4b5f569..1e6645bc8 100644 --- a/app/watchers/providers/docker/Docker.containers.details.test.ts +++ b/app/watchers/providers/docker/Docker.containers.details.test.ts @@ -1373,5 +1373,203 @@ 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'); + }); + }); + + describe('dd.inspect.tag.version-only opt-in label', () => { + test('should populate softwareVersion from inspect path but leave tag unchanged when dd.inspect.tag.version-only=true', async () => { + const container = await setupContainerDetailTest(docker, { + container: { + Image: 'ghcr.io/example/app:stable', + Names: ['/app'], + Labels: { + 'dd.inspect.tag.path': 'Config/Labels/my.app.version', + 'dd.inspect.tag.version-only': 'true', + }, + }, + imageDetails: { + Config: { + Labels: { 'my.app.version': '3.14.1' }, + }, + }, + parsedImage: { domain: 'ghcr.io', path: 'example/app', tag: 'stable' }, + semverValue: { major: 3, minor: 14, patch: 1, version: '3.14.1' }, + }); + hMockTag.transform.mockImplementation((_transform, value) => value); + + const result = await docker.addImageDetailsToContainer(container); + + // tag is NOT overwritten when version-only=true + expect(result.image.tag.value).toBe('stable'); + // softwareVersion is populated from the inspect path value + expect(result.image.softwareVersion).toBe('3.14.1'); + }); + + test('should fall back to OCI label for softwareVersion when inspect path yields no semver and version-only=true', async () => { + const container = await setupContainerDetailTest(docker, { + container: { + Image: 'ghcr.io/example/app:stable', + Names: ['/app'], + Labels: { + 'dd.inspect.tag.path': 'Config/Labels/nonexistent.version', + 'dd.inspect.tag.version-only': 'true', + }, + }, + imageDetails: { + Config: { + Labels: { 'org.opencontainers.image.version': '2.0.0' }, + }, + }, + parsedImage: { domain: 'ghcr.io', path: 'example/app', tag: 'stable' }, + semverValue: null, + }); + + const result = await docker.addImageDetailsToContainer(container); + + // tag unchanged since inspect path found no semver + expect(result.image.tag.value).toBe('stable'); + // softwareVersion falls back to OCI label + expect(result.image.softwareVersion).toBe('2.0.0'); + }); + + test('should dual-write softwareVersion from inspect path when version-only is absent (default behavior)', async () => { + const container = await setupContainerDetailTest(docker, { + container: { + Image: 'ghcr.io/example/app:stable', + Names: ['/app'], + Labels: { + 'dd.inspect.tag.path': 'Config/Labels/org.opencontainers.image.version', + }, + }, + imageDetails: { + Config: { + Labels: { 'org.opencontainers.image.version': '1.5.0' }, + }, + }, + parsedImage: { domain: 'ghcr.io', path: 'example/app', tag: 'stable' }, + semverValue: { major: 1, minor: 5, patch: 0, version: '1.5.0' }, + }); + hMockTag.transform.mockImplementation((_transform, value) => value); + + const result = await docker.addImageDetailsToContainer(container); + + // default: tag IS overwritten by inspect path value + expect(result.image.tag.value).toBe('1.5.0'); + // softwareVersion is also populated (dual-write) + expect(result.image.softwareVersion).toBe('1.5.0'); + }); + + test('should dual-write softwareVersion when inspect path points to non-OCI label', async () => { + const container = await setupContainerDetailTest(docker, { + container: { + Image: 'ghcr.io/example/app:latest', + Names: ['/app'], + Labels: { + 'dd.inspect.tag.path': 'Config/Labels/com.example.version', + }, + }, + imageDetails: { + Config: { + Labels: { 'com.example.version': '2.0.0' }, + }, + }, + parsedImage: { domain: 'ghcr.io', path: 'example/app', tag: 'latest' }, + semverValue: { major: 2, minor: 0, patch: 0, version: '2.0.0' }, + }); + hMockTag.transform.mockImplementation((_transform, value) => value); + + const result = await docker.addImageDetailsToContainer(container); + + // tag overwritten by inspect path (default, no version-only) + expect(result.image.tag.value).toBe('2.0.0'); + // softwareVersion populated even though OCI label is absent + expect(result.image.softwareVersion).toBe('2.0.0'); + }); + }); }); }); diff --git a/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts b/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts index 4be5777a9..0669940b4 100644 --- a/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts +++ b/app/watchers/providers/docker/Docker.containers.labels-version-finding.test.ts @@ -880,4 +880,15 @@ describe('Docker Watcher', () => { expect(result).toEqual({ tag: 'latest' }); }); }); + + describe('dd.inspect.tag.version-only label', () => { + test('testable_getLabel returns the value when dd.inspect.tag.version-only is present', () => { + const labels = { 'dd.inspect.tag.version-only': 'true' }; + expect(testable_getLabel(labels, 'dd.inspect.tag.version-only')).toBe('true'); + }); + + test('testable_getLabel returns undefined when dd.inspect.tag.version-only is absent', () => { + expect(testable_getLabel({}, 'dd.inspect.tag.version-only')).toBeUndefined(); + }); + }); }); diff --git a/app/watchers/providers/docker/Docker.ts b/app/watchers/providers/docker/Docker.ts index 5003d5b3a..347cd70dc 100644 --- a/app/watchers/providers/docker/Docker.ts +++ b/app/watchers/providers/docker/Docker.ts @@ -1437,6 +1437,7 @@ class Docker extends Watcher { inspectTagPath: string | undefined, transformTagsFromLabel: string | undefined, containerId: string, + inspectTagVersionOnly?: boolean, ) => this.resolveTagName( parsedImage, @@ -1444,6 +1445,7 @@ class Docker extends Watcher { inspectTagPath, transformTagsFromLabel, containerId, + inspectTagVersionOnly, ), getMatchingImgsetConfiguration: ( parsedImage: Parameters[0], @@ -1510,6 +1512,7 @@ class Docker extends Watcher { inspectTagPath: string | undefined, transformTagsFromLabel: string | undefined, containerId: string, + inspectTagVersionOnly?: boolean, ) { let tagName = parsedImage.tag || 'latest'; if (inspectTagPath) { @@ -1519,7 +1522,10 @@ class Docker extends Watcher { transformTagsFromLabel, ); if (semverTagFromInspect) { - tagName = semverTagFromInspect; + if (!inspectTagVersionOnly) { + tagName = semverTagFromInspect; // default: overwrite tag (behavior unchanged) + } + // dual-write happens in orchestration.ts softwareVersion field, not here } else { this.ensureLogger(); this.log.debug( diff --git a/app/watchers/providers/docker/container-init.ts b/app/watchers/providers/docker/container-init.ts index d12012f7c..bc99e4739 100644 --- a/app/watchers/providers/docker/container-init.ts +++ b/app/watchers/providers/docker/container-init.ts @@ -22,6 +22,7 @@ import { ddDisplayIcon, ddDisplayName, ddInspectTagPath, + ddInspectTagVersionOnly, ddLinkTemplate, ddNotificationExclude, ddNotificationInclude, @@ -64,6 +65,7 @@ interface ResolvedContainerLabelOverrides { transformTags?: string; tagFamily?: string; inspectTagPath?: string; + inspectTagVersionOnly?: string; linkTemplate?: string; displayName?: string; displayIcon?: string; @@ -148,6 +150,12 @@ const containerLabelOverrideMappings = [ wudKey: wudInspectTagPath, overrideKey: undefined, }, + { + key: 'inspectTagVersionOnly', + ddKey: ddInspectTagVersionOnly, + wudKey: undefined, + overrideKey: undefined, + }, { key: 'linkTemplate', ddKey: ddLinkTemplate, @@ -693,6 +701,7 @@ export function mergeConfigWithImgset( labelOverrides.inspectTagPath, matchingImgset?.inspectTagPath, ), + inspectTagVersionOnly: labelOverrides.inspectTagVersionOnly, watchDigest: getContainerConfigValue( getLabel(containerLabels, ddWatchDigest, wudWatchDigest), matchingImgset?.watchDigest, diff --git a/app/watchers/providers/docker/docker-image-details-orchestration.test.ts b/app/watchers/providers/docker/docker-image-details-orchestration.test.ts index 220f78214..91594c4fe 100644 --- a/app/watchers/providers/docker/docker-image-details-orchestration.test.ts +++ b/app/watchers/providers/docker/docker-image-details-orchestration.test.ts @@ -922,6 +922,7 @@ describe('docker image details orchestration module', () => { 'Config/Labels/org.opencontainers.image.version', 's/v//', 'container-1', + false, ); expect(watcher.log.debug).toHaveBeenCalledWith( 'Apply imgset "preferred" to container container-1', @@ -1475,4 +1476,76 @@ describe('docker image details orchestration module', () => { expect(containerInStore.sourceRepo).toBeUndefined(); }); + + test('calls resolveTagName with inspectTagVersionOnly=true when label is set', async () => { + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(undefined as any); + + const { watcher } = createWatcher(); + const resolveTagNameMock = vi.fn().mockReturnValue('stable'); + const helpers = createHelpers({ + resolveTagName: resolveTagNameMock, + mergeConfigWithImgset: vi.fn(() => ({ + includeTags: undefined, + excludeTags: undefined, + transformTags: undefined, + tagFamily: undefined, + linkTemplate: undefined, + displayName: undefined, + displayIcon: undefined, + triggerInclude: undefined, + triggerExclude: undefined, + watchDigest: undefined, + inspectTagPath: 'Config/Labels/app.version', + inspectTagVersionOnly: 'true', + lookupImage: undefined, + })), + }); + + await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer({ + Labels: { + 'dd.inspect.tag.path': 'Config/Labels/app.version', + 'dd.inspect.tag.version-only': 'true', + }, + }), + {}, + helpers as any, + ); + + expect(resolveTagNameMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'Config/Labels/app.version', + undefined, + 'container-1', + true, + ); + }); + + test('calls resolveTagName with inspectTagVersionOnly=false when label is absent', async () => { + vi.spyOn(storeContainer, 'getContainer').mockReturnValue(undefined as any); + + const { watcher } = createWatcher(); + const resolveTagNameMock = vi.fn().mockReturnValue('1.2.3'); + const helpers = createHelpers({ + resolveTagName: resolveTagNameMock, + }); + + await addImageDetailsToContainerOrchestration( + watcher as any, + createDockerSummaryContainer({ Labels: {} }), + {}, + helpers as any, + ); + + expect(resolveTagNameMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + undefined, + 'container-1', + false, + ); + }); }); diff --git a/app/watchers/providers/docker/docker-image-details-orchestration.ts b/app/watchers/providers/docker/docker-image-details-orchestration.ts index 7b14d48f0..bbde9072b 100644 --- a/app/watchers/providers/docker/docker-image-details-orchestration.ts +++ b/app/watchers/providers/docker/docker-image-details-orchestration.ts @@ -18,7 +18,9 @@ import { canonicalizeContainerName, getContainerDisplayName, getContainerName, + getInspectValueByPath, getRepoDigest, + getSemverTagFromInspectPath, isDigestToWatch, type ResolvedImgset, shouldUpdateDisplayNameFromContainerName, @@ -96,6 +98,7 @@ interface ResolvedContainerLabelOverrides { triggerExclude?: string; lookupImage?: string; inspectTagPath?: string; + inspectTagVersionOnly?: string; } interface ResolvedContainerConfig { @@ -110,6 +113,7 @@ interface ResolvedContainerConfig { triggerExclude?: string; lookupImage?: string; inspectTagPath?: string; + inspectTagVersionOnly?: string; watchDigest?: string; } @@ -167,6 +171,7 @@ interface DockerImageDetailsHelpers { inspectTagPath: string | undefined, transformTagsFromLabel: string | undefined, containerId: string, + inspectTagVersionOnly?: boolean, ) => string; getMatchingImgsetConfiguration: ( parsedImage: ParsedDockerImageReference, @@ -525,6 +530,7 @@ function resolveContainerImageState( resolvedConfig.inspectTagPath, resolvedLabelOverrides.transformTags, container.Id, + resolvedConfig.inspectTagVersionOnly?.toLowerCase() === 'true', ); const transformedTag = transformTag(resolvedConfig.transformTags, tagName); const parsedTag = parseSemver(transformedTag); @@ -583,6 +589,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 +764,17 @@ export async function addImageDetailsToContainerOrchestration( os: image.Os, variant: image.Variant, created: image.Created, + softwareVersion: (() => { + if (resolvedConfig.inspectTagPath) { + const fromInspectPath = getSemverTagFromInspectPath( + image, + resolvedConfig.inspectTagPath, + resolvedConfig.transformTags, + ); + if (fromInspectPath) return fromInspectPath; + } + return resolveSoftwareVersion(image, containerInspect); + })(), }, labels: containerLabels, sourceRepo: detectSourceRepoFromImageMetadata({ diff --git a/app/watchers/providers/docker/label.ts b/app/watchers/providers/docker/label.ts index 35f349e25..c14525165 100644 --- a/app/watchers/providers/docker/label.ts +++ b/app/watchers/providers/docker/label.ts @@ -37,6 +37,12 @@ export const ddTagFamily = 'dd.tag.family'; export const ddInspectTagPath = 'dd.inspect.tag.path'; export const wudInspectTagPath = 'wud.inspect.tag.path'; +/** + * When set to 'true', routes dd.inspect.tag.path to image.softwareVersion + * only, preserving the real image tag for update detection. Default: off. + */ +export const ddInspectTagVersionOnly = 'dd.inspect.tag.version-only'; + /** * Optional image reference to use for update lookups. */ 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/content/docs/current/configuration/watchers/index.mdx b/content/docs/current/configuration/watchers/index.mdx index 4e89eaa23..4f78cf390 100644 --- a/content/docs/current/configuration/watchers/index.mdx +++ b/content/docs/current/configuration/watchers/index.mdx @@ -717,7 +717,8 @@ To fine-tune the behaviour of drydock _per container_, you can add labels on the | `dd.display.picture` | ⚪ | Custom entity picture URL for Home Assistant MQTT integration. When set to an HTTP/HTTPS URL, overrides the icon-derived `entity_picture` in HASS discovery payloads. | Valid HTTP or HTTPS URL | | | `dd.display.name` | ⚪ | Custom display name for the container | Valid String | Container name | | `dd.group` | ⚪ | Group name for stack/group views in the UI (falls back to `com.docker.compose.project`, then the Swarm stack label `com.docker.stack.namespace`, if not set) | Valid String | | -| `dd.inspect.tag.path` | ⚪ | Docker inspect path used to derive a local semver tag | Slash-separated path in `docker inspect` output | | +| `dd.inspect.tag.path` | ⚪ | Docker inspect path used to derive a local semver tag. The extracted value overwrites the image tag (for update detection) **and** is written to `image.softwareVersion` (for the Version column). Use `dd.inspect.tag.version-only=true` to route it to `image.softwareVersion` only. | Slash-separated path in `docker inspect` output | | +| `dd.inspect.tag.version-only` | ⚪ | When `dd.inspect.tag.path` is set, route the extracted value to `image.softwareVersion` only instead of overwriting the image tag. Default behavior (tag overwrite) is unchanged when this label is absent or `false`. | `true`, `false` | `false` | | `dd.registry.lookup.image` | ⚪ | Alternative image reference used for update lookups | Full image path (for example `library/traefik` or `ghcr.io/traefik/traefik`) | | | `dd.link.template` | ⚪ | Browsable link associated to the container version | JS string template with vars `${raw}`, `${original}`, `${transformed}`, `${major}`, `${minor}`, `${patch}`, `${prerelease}` | | | `dd.tag.exclude` | ⚪ | Regex to exclude specific tags | Valid JavaScript Regex | | @@ -748,7 +749,7 @@ To fine-tune the behaviour of drydock _per container_, you can add labels on the `dd.runtime.entrypoint.origin` and `dd.runtime.cmd.origin` are managed automatically by the Docker trigger during container updates. You only need to set them manually if drydock cannot detect the origin (shows `unknown`) and you want to explicitly pin or release a custom entrypoint/cmd. -`dd.inspect.tag.path` is optional and opt-in. Use it only when your image metadata tracks the running app version reliably; some images set unrelated values. Also note that legacy alias `dd.registry.lookup.url` is still accepted for compatibility, but prefer `dd.registry.lookup.image`. +`dd.inspect.tag.path` is optional and opt-in. Use it only when your image metadata tracks the running app version reliably; some images set unrelated values. By default the extracted value overwrites the image tag (enabling update detection against the embedded semver) and is also written to `image.softwareVersion` (shown in the Version column). Add `dd.inspect.tag.version-only=true` when you want the Version column populated without changing how drydock matches updates. Also note that legacy alias `dd.registry.lookup.url` is still accepted for compatibility, but prefer `dd.registry.lookup.image`. `dd.tag.transform` regex patterns are validated at config time. A malformed or oversized regex pattern causes drydock to throw at startup with a descriptive error rather than silently producing broken tag transformations that could generate false positive update detections. @@ -878,6 +879,36 @@ docker run -d \ +### Show the app version from inspect without changing update detection + +Use `dd.inspect.tag.version-only=true` when the inspect path value is a display-only version string that would break update detection if used as the image tag (for example when the registry tag and the embedded app version differ in format). + + + + +```yaml +services: + myapp: + image: ghcr.io/example/myapp:latest + labels: + - dd.inspect.tag.path=Config/Labels/org.opencontainers.image.version + - dd.inspect.tag.version-only=true +``` + +The extracted version (e.g. `2.14.3`) appears in the **Version** column. The **Tag** column continues to show `latest` and digest-based update detection is unaffected. + + + +```bash +docker run -d \ + --name myapp \ + --label dd.inspect.tag.path=Config/Labels/org.opencontainers.image.version \ + --label dd.inspect.tag.version-only=true \ + ghcr.io/example/myapp:latest +``` + + + ### Use an alternative image for update lookups Use this when your runtime image is pulled from a cache/proxy registry, but you want updates checked against an upstream image. @@ -1157,9 +1188,16 @@ drydock classifies each container's tag as `specific` (pinned release, e.g. `1.4 The `tagPrecision` field is computed from the tag name and any active `dd.tag.transform`. You do not need to configure it manually. +### Tag and Version columns + +The containers table has two distinct columns for version information: + +- **Tag** (column key `version`) — the image tag used for update detection (e.g. `latest`, `1.4.5`). This column was called "Version" in releases before v1.5.1; the column key is unchanged so saved column preferences keep working. +- **Version** (column key `softwareVersion`) — the application version surfaced from `image.softwareVersion` (the `org.opencontainers.image.version` OCI label, a `dd.inspect.tag.path` extract, or other runtime metadata). Falls back to the image tag when `softwareVersion` is not available. Visible by default for new installs; added automatically to saved column lists on upgrade. + ### Version column display -The Containers list Version column adapts based on `tagPrecision`: +The **Tag** column adapts based on `tagPrecision`: - **Floating-tag + digest-watch containers** (e.g. `prom/prometheus:latest`): shows the human-readable tag (not raw SHA). The digest transition is available in the cell tooltip and detail panels. - **Digest-pinned containers** (`image.tag.value` starts with `sha256:`): shows the `sha256:abc… → sha256:def…` pair directly in the cell. diff --git a/ui/src/components/DataTable.vue b/ui/src/components/DataTable.vue index 8e476767e..a6cf2a45e 100644 --- a/ui/src/components/DataTable.vue +++ b/ui/src/components/DataTable.vue @@ -32,6 +32,8 @@ export interface DataTableColumn { px?: string; /** Narrow icon-only column — no header text, tight padding, vertically centered */ icon?: boolean; + /** Optional tooltip shown on the column header label */ + headerTooltip?: string; } const props = withDefaults( @@ -730,7 +732,7 @@ function handleHeaderKeydown(event: KeyboardEvent, col: DataTableColumn) { :aria-sort="ariaSort(col)" @keydown="handleHeaderKeydown($event, col)" @click="!resizing && isSortableColumn(col) && toggleSort(col.key, sortKey, sortAsc)"> - {{ col.label }} + {{ col.label }} {{ sortAsc ? '\u25B2' : '\u25BC' }}
{{ detailPreview.updateKind?.kind || detailPreview.updateKind || t('common.unknown') }}
{{ t('containerComponents.fullPageActions.runningLabel') }} - {{ detailPreview.isRunning ? 'yes' : 'no' }} + {{ detailPreview.isRunning ? t('common.yes') : t('common.no') }}
{{ t('containerComponents.fullPageActions.networksLabel') }} {{ detailPreview.networks.join(', ') || '-' }} @@ -282,7 +282,7 @@ function isUpdateHardBlocked(container: { updateEligibility?: UpdateEligibility
{{ t('containerComponents.fullPageActions.writesComposeFileLabel') }} - {{ detailComposePreview.willWrite ? 'yes' : 'no' }} + {{ detailComposePreview.willWrite ? t('common.yes') : t('common.no') }}
{{ t('containerComponents.fullPageActions.patchPreviewLabel') }} @@ -314,7 +314,7 @@ function isUpdateHardBlocked(container: { updateEligibility?: UpdateEligibility :style="{ backgroundColor: 'var(--dd-bg-inset)' }">
{{ trigger.type }}.{{ trigger.name }}
-
agent: {{ trigger.agent }}
+
{{ t('containerComponents.triggers.agentLabel') }} {{ trigger.agent }}
diff --git a/ui/src/components/containers/ContainerFullPageDetail.vue b/ui/src/components/containers/ContainerFullPageDetail.vue index c5939915b..c58919bde 100644 --- a/ui/src/components/containers/ContainerFullPageDetail.vue +++ b/ui/src/components/containers/ContainerFullPageDetail.vue @@ -29,6 +29,8 @@ const { confirmStop, startContainer, confirmRestart, + recheckContainer, + recheckingContainerId, scanContainer, confirmUpdate, confirmForceUpdate, @@ -225,6 +227,16 @@ function getStatusTone(container: { id?: unknown; name?: unknown; status?: strin @click="scanContainer(selectedContainer)"> {{ t('containerComponents.fullPageDetail.scanButton') }} + + + + {{ t('containerComponents.fullPageDetail.recheckButton') }} ; @@ -149,6 +151,8 @@ const { formatOperationPhase, formatRollbackReason, updateOperationsError, + recheckContainer, + recheckingContainerId, scanContainer, confirmUpdate, confirmForceUpdate, @@ -158,6 +162,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 +282,13 @@ function getUpdateKindLabel(kind: Container['updateKind']) { {{ selectedContainer.isDigestPinned && selectedContainer.currentDigest ? formatShortDigest(selectedContainer.currentDigest) : selectedContainer.currentTag }}
+
+ {{ t('containerComponents.fullPageOverview.softwareVersion') }} + {{ selectedContainer.softwareVersion }} +
@@ -426,6 +443,16 @@ function getUpdateKindLabel(kind: Container['updateKind']) { {{ runtimeOriginLabel(selectedRuntimeOrigins.cmd) }}
+
+ + {{ t('containerComponents.fullPageOverview.uptime') }} + + + {{ uptimeString }} + +
{{ t('containerComponents.fullPageActions.scanNow') }} + + {{ recheckingContainerId === selectedContainer.id ? t('containerComponents.fullPageActions.rechecking') : t('containerComponents.fullPageActions.recheckNow') }} +
@@ -921,7 +953,7 @@ function getUpdateKindLabel(kind: Container['updateKind']) { :style="{ backgroundColor: 'var(--dd-bg-inset)' }">
{{ trigger.type }}.{{ trigger.name }}
-
agent: {{ trigger.agent }}
+
{{ t('containerComponents.triggers.agentLabel') }} {{ trigger.agent }}
diff --git a/ui/src/components/containers/ContainerSideDetail.vue b/ui/src/components/containers/ContainerSideDetail.vue index 67d3b8060..921427135 100644 --- a/ui/src/components/containers/ContainerSideDetail.vue +++ b/ui/src/components/containers/ContainerSideDetail.vue @@ -37,6 +37,8 @@ const { confirmStop, startContainer, confirmRestart, + recheckContainer, + recheckingContainerId, scanContainer, confirmUpdate, confirmForceUpdate, @@ -143,6 +145,14 @@ function getStatusTone(container: { id?: unknown; name?: unknown; status?: strin :disabled="isActionBlocked(selectedContainer)" :tooltip="t('containerComponents.sideDetail.scanTooltip')" @click="scanContainer(selectedContainer)" /> + {{ formatShortDigest(selectedContainer.newDigest) }} +
+ {{ t('containerComponents.fullPageOverview.softwareVersion') }} + {{ selectedContainer.softwareVersion }} +
{{ t('containerComponents.fullPageActions.scanNow') }}
+ + {{ recheckingContainerId === selectedContainer.id ? t('containerComponents.fullPageActions.rechecking') : t('containerComponents.fullPageActions.recheckNow') }} + @@ -846,7 +860,7 @@ function getUpdateKindLabel(kind: Container['updateKind']) { {{ detailPreview.updateKind?.kind || detailPreview.updateKind || t('common.unknown') }}
{{ t('containerComponents.fullPageActions.runningLabel') }} - {{ detailPreview.isRunning ? 'yes' : 'no' }} + {{ detailPreview.isRunning ? t('common.yes') : t('common.no') }}
{{ t('containerComponents.fullPageActions.networksLabel') }} {{ detailPreview.networks.join(', ') || '-' }} @@ -865,7 +879,7 @@ function getUpdateKindLabel(kind: Container['updateKind']) {
{{ t('containerComponents.fullPageActions.writesComposeFileLabel') }} - {{ detailComposePreview.willWrite ? 'yes' : 'no' }} + {{ detailComposePreview.willWrite ? t('common.yes') : t('common.no') }}
{{ t('containerComponents.fullPageActions.patchPreviewLabel') }} @@ -891,7 +905,7 @@ function getUpdateKindLabel(kind: Container['updateKind']) { :style="{ backgroundColor: 'var(--dd-bg-inset)' }">
{{ trigger.type }}.{{ trigger.name }}
-
agent: {{ trigger.agent }}
+
{{ t('containerComponents.triggers.agentLabel') }} {{ trigger.agent }}
visibleColumns.value.has('uptime')); const { t, te } = useI18n(); const { batches, clearBatch, getBatch, incrementSucceeded, incrementFailed } = useUpdateBatches(); @@ -702,6 +708,16 @@ onScopeDispose(() => {
+ + + +