From 3c5d2a427ea2269e787db00c263f8c2b304c2ac2 Mon Sep 17 00:00:00 2001 From: Jamie White Date: Tue, 19 Oct 2021 16:19:40 +0200 Subject: [PATCH] ui: improve docker reference parsing Closes #2484 --- .changelog/2518.txt | 3 + .../components/container-image-tag/index.ts | 4 +- ui/app/components/git-commit.hbs | 3 +- ui/app/components/image-ref.hbs | 28 ++++++--- ui/app/components/image-ref.ts | 51 ++++++++++++++++ ui/app/components/resource-detail.ts | 4 +- .../status-report-meta-table/index.hbs | 24 +++++--- ui/app/styles/_variables.scss | 5 +- ui/app/styles/components/image-ref.scss | 26 +++++++-- .../navigation/artifact-overview.scss | 41 ++++++++----- ui/app/styles/components/resource-detail.scss | 4 ++ ui/app/utils/image-refs.ts | 40 +++++-------- ui/mirage/factories/resource.ts | 11 +++- ui/package.json | 1 + .../components/container-image-tag-test.ts | 58 ++++++++++++++++++- ui/translations/en-us.yaml | 5 +- ui/types/docker-parse-image.d.ts | 10 ++++ ui/yarn.lock | 5 ++ 18 files changed, 251 insertions(+), 72 deletions(-) create mode 100644 .changelog/2518.txt create mode 100644 ui/app/components/image-ref.ts create mode 100644 ui/types/docker-parse-image.d.ts diff --git a/.changelog/2518.txt b/.changelog/2518.txt new file mode 100644 index 00000000000..91ccc27dbfc --- /dev/null +++ b/.changelog/2518.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: improve docker reference parsing +``` \ No newline at end of file diff --git a/ui/app/components/container-image-tag/index.ts b/ui/app/components/container-image-tag/index.ts index ed7bcdc18d1..4787fe7db3f 100644 --- a/ui/app/components/container-image-tag/index.ts +++ b/ui/app/components/container-image-tag/index.ts @@ -2,7 +2,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import ApiService from 'waypoint/services/api'; import { StatusReport } from 'waypoint-pb'; -import { ImageRef, findImageRefs } from 'waypoint/utils/image-refs'; +import { findImageRefs } from 'waypoint/utils/image-refs'; interface Args { statusReport: StatusReport.AsObject; @@ -17,7 +17,7 @@ export default class extends Component { : []; } - get imageRefs(): ImageRef[] { + get imageRefs(): ReturnType { return findImageRefs(this.states); } } diff --git a/ui/app/components/git-commit.hbs b/ui/app/components/git-commit.hbs index 6c24ef7f8c5..51bf32aa814 100644 --- a/ui/app/components/git-commit.hbs +++ b/ui/app/components/git-commit.hbs @@ -4,5 +4,4 @@ {{truncate-commit @commit}} -{{/if}} - +{{/if}} \ No newline at end of file diff --git a/ui/app/components/image-ref.hbs b/ui/app/components/image-ref.hbs index 4e539adae2a..730fe273a17 100644 --- a/ui/app/components/image-ref.hbs +++ b/ui/app/components/image-ref.hbs @@ -1,12 +1,22 @@ - - {{@imageRef.label}} - - - {{#if (eq @imageRef.label "sha256")}} - {{truncate-commit @imageRef.tag}} - {{else}} - {{@imageRef.tag}} - {{/if}} + + {{this.uri}} + + {{#if this.hasTag}} + + {{this.presentableTag}} + + {{/if}} + + {{#if this.tagIsDigest}} + + + + {{/if}} diff --git a/ui/app/components/image-ref.ts b/ui/app/components/image-ref.ts new file mode 100644 index 00000000000..137afc2aa3d --- /dev/null +++ b/ui/app/components/image-ref.ts @@ -0,0 +1,51 @@ +import Ember from 'ember'; +import Component from '@glimmer/component'; +import { Ref } from 'docker-parse-image'; +import { action } from '@ember/object'; +import { TaskGenerator, task, timeout } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; + +type Args = { + imageRef?: Ref; +}; + +export default class extends Component { + get uri(): string { + if (!this.args.imageRef) { + return ''; + } + + let { registry, namespace, repository } = this.args.imageRef; + + return [registry, namespace, repository].filter(Boolean).join('/'); + } + + get hasTag(): boolean { + return !!this.args.imageRef?.tag; + } + + get tagIsDigest(): boolean { + return this.args.imageRef?.tag?.includes(':') ?? false; + } + + get presentableTag(): string | undefined { + let tag = this.args.imageRef?.tag; + + if (!tag) { + return; + } + + if (this.tagIsDigest) { + let [alg, digest] = tag.split(':'); + return `${alg}:${digest.substr(0, 7)}`; + } + + return tag; + } + + @task({ restartable: true }) + *displayCopySuccess(): TaskGenerator { + let duration = Ember.testing ? 0 : 2000; + yield timeout(duration); + } +} diff --git a/ui/app/components/resource-detail.ts b/ui/app/components/resource-detail.ts index 0a0158f7988..d6f1629afb5 100644 --- a/ui/app/components/resource-detail.ts +++ b/ui/app/components/resource-detail.ts @@ -2,7 +2,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import RouterService from '@ember/routing/router-service'; import { StatusReport } from 'waypoint-pb'; -import { ImageRef, findImageRefs } from 'waypoint/utils/image-refs'; +import { findImageRefs } from 'waypoint/utils/image-refs'; interface Args { resource?: StatusReport.Resource.AsObject; @@ -37,7 +37,7 @@ export default class extends Component { return this.state?.pod?.metadata?.labels; } - get imageRefs(): ImageRef[] { + get imageRefs(): ReturnType { return findImageRefs(this.state); } diff --git a/ui/app/components/status-report-meta-table/index.hbs b/ui/app/components/status-report-meta-table/index.hbs index ac01deb0054..2e3067e0ee0 100644 --- a/ui/app/components/status-report-meta-table/index.hbs +++ b/ui/app/components/status-report-meta-table/index.hbs @@ -2,25 +2,31 @@ {{#if @model.statusReport}} - - - - + + + + {{else}} {{t "page.deployment.overview.unavailable"}} {{/if}} diff --git a/ui/app/styles/_variables.scss b/ui/app/styles/_variables.scss index 1d004e6b29f..97266bb34df 100644 --- a/ui/app/styles/_variables.scss +++ b/ui/app/styles/_variables.scss @@ -17,7 +17,6 @@ $button-shadow: 0 3px 2px rgba(var(--shadow), 0.2); --text: #{dehex(color.$black)}; --text-muted: #{dehex(color.$ui-cool-gray-600)}; --text-subtle: #{dehex(color.$ui-cool-gray-400)}; - --tag-background: #{dehex(color.$ui-gray-100)}; --link: #{dehex(color.$blue-500)}; --border: #{dehex(color.$ui-gray-200)}; --outline: #{dehex(color.$ui-gray-300)}; @@ -25,6 +24,8 @@ $button-shadow: 0 3px 2px rgba(var(--shadow), 0.2); --panel: #{dehex(color.$ui-cool-gray-050)}; --badge: #{dehex(color.$blue-050)}; --badge-text: #{dehex(color.$blue-500)}; + --badge-neutral: #{dehex(color.$ui-cool-gray-100)}; + --badge-neutral-text: #{dehex(color.$ui-cool-gray-700)}; --shadow: #{dehex(color.$ui-cool-gray-900)}; --focus-ring: #{dehex(color.$blue-500)}; --success: #{dehex(color.$green-050)}; @@ -57,6 +58,8 @@ $button-shadow: 0 3px 2px rgba(var(--shadow), 0.2); --focus-ring: #{dehex(color.$blue-400)}; --badge: #{dehex(color.$ui-gray-800)}; --badge-text: #{dehex(color.$ui-gray-300)}; + --badge-neutral: #{dehex(color.$ui-gray-800)}; + --badge-neutral-text: #{dehex(color.$ui-gray-300)}; --success: #{dehex(color.$green-900)}; --success-text: #{dehex(color.$green-400)}; --success-border: #{dehex(color.$green-800)}; diff --git a/ui/app/styles/components/image-ref.scss b/ui/app/styles/components/image-ref.scss index e3e5e917b86..74af22e7a5e 100644 --- a/ui/app/styles/components/image-ref.scss +++ b/ui/app/styles/components/image-ref.scss @@ -3,10 +3,28 @@ align-items: center; &__tag { - background: rgb(var(--tag-background)); - color: rgb(var(--text-muted)); - border-radius: 2px; - padding: 0 4px; margin-left: 7px; } + + &__copy-button { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + color: inherit; + font-size: inherit; + line-height: inherit; + + padding: scale.$sm--4; + border: 0; + margin: 0; + margin-left: 0.25rem; + + appearance: none; + cursor: pointer; + } + + & + & { + margin-top: scale.$sm--4; + } } diff --git a/ui/app/styles/components/navigation/artifact-overview.scss b/ui/app/styles/components/navigation/artifact-overview.scss index 29c76ed0d22..5179bc0aaf4 100644 --- a/ui/app/styles/components/navigation/artifact-overview.scss +++ b/ui/app/styles/components/navigation/artifact-overview.scss @@ -1,35 +1,46 @@ .artifact-overview { padding-bottom: 0.5rem; + h2 { border-bottom: 1px solid rgb(var(--border)); padding-bottom: 0.5rem; } table { - padding: 0.5rem 0; + padding: 0; font-size: 0.875rem; + line-height: 17px; - tr { - th { - font-weight: 500; - color: rgb(var(--text-muted)); - width: 158px; - text-align: left; - padding-bottom: 0.25rem; - } + th, + td { + padding-left: 0; + padding-top: scale.$sm--2; + padding-bottom: scale.$sm--2; + vertical-align: top; + } - .status-indicator { - display: flex; - align-items: flex-end; - padding-bottom: 0.25rem; - } + th { + font-weight: normal; + color: rgb(var(--text-muted)); + padding-right: scale.$lg--10; + text-align: left; } - button { + .status-indicator { + display: flex; + align-items: flex-end; + padding-bottom: 0.25rem; + } + + .refresh-health-check-button { padding: 0 0.25rem; height: inherit; display: flex; align-items: baseline; } + + .image-ref:first-child { + margin-top: -1px; + } } } diff --git a/ui/app/styles/components/resource-detail.scss b/ui/app/styles/components/resource-detail.scss index 2316caa473d..33e533608ea 100644 --- a/ui/app/styles/components/resource-detail.scss +++ b/ui/app/styles/components/resource-detail.scss @@ -20,6 +20,10 @@ padding-bottom: scale.$sm--2; vertical-align: top; } + + .image-ref:first-child { + margin-top: -1px; + } } &__flex { diff --git a/ui/app/utils/image-refs.ts b/ui/app/utils/image-refs.ts index 6a487fdd4fc..0d12e6ef354 100644 --- a/ui/app/utils/image-refs.ts +++ b/ui/app/utils/image-refs.ts @@ -1,31 +1,13 @@ -export class ImageRef { - ref: string; - - constructor(ref: string) { - this.ref = ref; - } - - get label(): string { - return this.split[0]; - } - - get tag(): string { - return this.split[1]; - } - - private get split(): string[] { - return this.ref.split(':'); - } -} +import parse, { Ref } from 'docker-parse-image'; /** * Returns a flat map of values for `image` properties within the given object. * * @param {object|array} obj search space - * @param {ImageRef[]} [result=[]] starting result array (used internally, usually no need to pass this) - * @returns {ImageRef[]} an array of found ImageRefs + * @param {Ref[]} [result=[]] starting result array (used internally, usually no need to pass this) + * @returns {Ref[]} an array of found ImageRefs */ -export function findImageRefs(obj: unknown, result: ImageRef[] = []): ImageRef[] { +export function findImageRefs(obj: unknown, result: Ref[] = []): Ref[] { if (typeof obj !== 'object') { return result; } @@ -36,9 +18,19 @@ export function findImageRefs(obj: unknown, result: ImageRef[] = []): ImageRef[] for (let [key, value] of Object.entries(obj)) { if (key.toLowerCase() === 'image' && typeof value === 'string') { - if (!result.some((image) => image.ref === value)) { - result.push(new ImageRef(value)); + if (result[value]) { + // We’ve already seen this ref, continue. + continue; } + + let ref = parse(value); + + result.push(ref); + + // The result array also acts as a map of ref strings to the resultant Ref + // objects. This little trick is purely internal. Think of it as a “seen” + // list. + result[value] = ref; } else { findImageRefs(value, result); } diff --git a/ui/mirage/factories/resource.ts b/ui/mirage/factories/resource.ts index b53862624c4..0194185aa13 100644 --- a/ui/mirage/factories/resource.ts +++ b/ui/mirage/factories/resource.ts @@ -57,7 +57,16 @@ export default Factory.extend({ spec: { containers: [ { - image: 'marketing-public/wp-matrix:1', + image: 'marketing-public/wp-matrix@sha256:c47cbb1d0526ad29183fb14919ff6c757ec31173', + }, + { + image: 'localhost:5000/wp-matrix:a-very-long-but-still-human-readable-tag', + }, + { + image: 'marketing-public/wp-matrix:latest', + }, + { + image: 'quay.io/marketing-public/wp-matrix', }, ], }, diff --git a/ui/package.json b/ui/package.json index df742a654c5..72c84688b69 100644 --- a/ui/package.json +++ b/ui/package.json @@ -51,6 +51,7 @@ "broccoli-asset-rev": "^3.0.0", "codemirror": "^5.62.2", "date-fns": "^2.15.0", + "docker-parse-image": "^3.0.1", "ember-a11y-testing": "^4.0.7", "ember-auto-import": "^1.10.1", "ember-auto-import-typescript": "^0.4.0", diff --git a/ui/tests/integration/components/container-image-tag-test.ts b/ui/tests/integration/components/container-image-tag-test.ts index f7cd595ab7d..68d8699dbc8 100644 --- a/ui/tests/integration/components/container-image-tag-test.ts +++ b/ui/tests/integration/components/container-image-tag-test.ts @@ -17,14 +17,17 @@ module('Integration | Component | container-image-tag', function (hooks) { }); await render(hbs``); - assert.equal(this.element?.textContent?.trim().replace(/\s+/, ' '), 'docker tag'); + + assert.dom('[data-test-image-ref-uri]').hasText('docker'); + assert.dom('[data-test-image-ref-tag]').hasText('tag'); }); test('it renders n/a when a status report does not exist', async function (assert) { this.set('statusReport2', {}); await render(hbs``); - assert.equal(this.element?.textContent?.trim().replace(/\s+/, ' '), 'n/a'); + + assert.dom(this.element).hasText('n/a'); }); test('it renders multiple tags', async function (assert) { @@ -39,4 +42,55 @@ module('Integration | Component | container-image-tag', function (hooks) { await render(hbs``); assert.dom('[data-test-image-ref]').exists({ count: 2 }); }); + + test('it handles refs like "localhost:5000/image-name:latest"', async function (assert) { + this.set('statusReport', { + resourcesList: [ + { + type: 'container', + stateJson: '{"Config": {"Image": "localhost:5000/image-name:latest"}}', + }, + ], + }); + + await render(hbs``); + + assert.dom('[data-test-image-ref-uri]').hasText('localhost:5000/image-name'); + assert.dom('[data-test-image-ref-tag]').hasText('latest'); + }); + + test('it handles refs like "localhost:5000/image-name"', async function (assert) { + this.set('statusReport', { + resourcesList: [ + { + type: 'container', + stateJson: '{"Config": {"Image": "localhost:5000/image-name"}}', + }, + ], + }); + + await render(hbs``); + + assert.dom('[data-test-image-ref-uri]').hasText('localhost:5000/image-name'); + assert.dom('[data-test-image-ref-tag]').doesNotExist(); + }); + + test('it handles refs with digests', async function (assert) { + this.set('statusReport', { + resourcesList: [ + { + type: 'container', + stateJson: + '{"Config": {"Image": "localhost:5000/image-name@sha256:aaaaf56b44807c64d294e6c8059b479f35350b454492398225034174808d1726"}}', + }, + ], + }); + + await render(hbs``); + + assert.dom('[data-test-image-ref-uri]').hasText('localhost:5000/image-name'); + assert + .dom('[data-test-image-ref-tag]') + .hasText('sha256:aaaaf56b44807c64d294e6c8059b479f35350b454492398225034174808d1726'); + }); }); diff --git a/ui/translations/en-us.yaml b/ui/translations/en-us.yaml index 4602503c5ff..8427f2c5df8 100644 --- a/ui/translations/en-us.yaml +++ b/ui/translations/en-us.yaml @@ -38,7 +38,7 @@ page: overview: heading: Overview image: Image - health-check: Health Check + health-check: Health re-run-health-check: Re-run unavailable: Currently unavailable logs: @@ -232,3 +232,6 @@ resource-detail: section: expand: Expand section collapse: Collapse section + +image-ref: + copy-button-title: Copy full digest diff --git a/ui/types/docker-parse-image.d.ts b/ui/types/docker-parse-image.d.ts new file mode 100644 index 00000000000..869df876050 --- /dev/null +++ b/ui/types/docker-parse-image.d.ts @@ -0,0 +1,10 @@ +declare module 'docker-parse-image' { + export default function parse(s: string): Ref; + + export interface Ref { + registry?: string; + namespace?: string; + repository?: string; + tag?: string; + } +} diff --git a/ui/yarn.lock b/ui/yarn.lock index 94af4088cae..be16ff7e6b5 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -5891,6 +5891,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +docker-parse-image@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/docker-parse-image/-/docker-parse-image-3.0.1.tgz#33dc69291eac3414f84871f2d59d77b6f6948be4" + integrity sha1-M9xpKR6sNBT4SHHy1Z13tvaUi+Q= + doctrine@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
- {{t "page.deployment.overview.image"}} - - -
{{t "page.deployment.overview.health-check"}}
  - + {{t "page.deployment.overview.re-run-health-check"}}
+ {{t "page.deployment.overview.image"}} + + +