From e9a83c23802518ac1b89f4d0bc400b014b27a699 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 24 Nov 2023 14:55:42 +0100 Subject: [PATCH] Image Checker UI (#4859) * feat: image checker ui Signed-off-by: Philippe Martin * test: unit tests Signed-off-by: Philippe Martin * feat: fake extension Signed-off-by: Philippe Martin * fix: apply suggestions from code review Co-authored-by: Florent BENOIT Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin * fix: display error when provider fails Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin * chore: add telemetry Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin * fix: abort checks when user leaves the tab Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin * fix: tests Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin * fix: image-checker-provider-remove and consts Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin * Revert "feat: fake extension" This reverts commit 69962c6319fba7186c01e8a5f793b7eb21072735. Signed-off-by: Philippe Martin * fix: new design Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin Signed-off-by: Philippe Martin Co-authored-by: Florent BENOIT --- .../src/lib/image/ImageDetails.spec.ts | 57 ++++ .../src/lib/image/ImageDetails.svelte | 23 +- .../src/lib/image/ImageDetailsCheck.spec.ts | 281 ++++++++++++++++++ .../src/lib/image/ImageDetailsCheck.svelte | 131 ++++++++ .../src/lib/ui/ProviderResultPage.svelte | 94 ++++++ .../renderer/src/lib/ui/ProviderResultPage.ts | 13 + .../src/stores/image-checker-providers.ts | 51 ++++ 7 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 packages/renderer/src/lib/image/ImageDetailsCheck.spec.ts create mode 100644 packages/renderer/src/lib/image/ImageDetailsCheck.svelte create mode 100644 packages/renderer/src/lib/ui/ProviderResultPage.svelte create mode 100644 packages/renderer/src/lib/ui/ProviderResultPage.ts create mode 100644 packages/renderer/src/stores/image-checker-providers.ts diff --git a/packages/renderer/src/lib/image/ImageDetails.spec.ts b/packages/renderer/src/lib/image/ImageDetails.spec.ts index af4856fba4c80..18edc119dda85 100644 --- a/packages/renderer/src/lib/image/ImageDetails.spec.ts +++ b/packages/renderer/src/lib/image/ImageDetails.spec.ts @@ -29,6 +29,7 @@ import { router } from 'tinro'; import { lastPage } from '/@/stores/breadcrumb'; import type { ContainerInfo } from '../../../../main/src/plugin/api/container-info'; import { containersInfos } from '/@/stores/containers'; +import { imageCheckerProviders } from '/@/stores/image-checker-providers'; const listImagesMock = vi.fn(); const getContributedMenusMock = vi.fn(); @@ -203,3 +204,59 @@ describe('expect display usage of an image', () => { expect(usage).toBeInTheDocument(); }); }); + +test('expect Check tab is not displayed by default', () => { + const imageID = '123456'; + const engineId = 'podman'; + const myImage = { + engineId, + Id: imageID, + Size: 0, + } as unknown as ImageInfo; + imagesInfos.set([myImage]); + + hasAuthMock.mockImplementation(() => { + return new Promise(() => false); + }); + + render(ImageDetails, { + imageID, + engineId, + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + }); + const summaryTab = screen.getByRole('link', { name: 'Summary' }); + expect(summaryTab).toBeInTheDocument(); + const checkTab = screen.queryByRole('link', { name: 'Check' }); + expect(checkTab).not.toBeInTheDocument(); +}); + +test('expect Check tab is displayed when an image checker provider exists', () => { + const imageID = '123456'; + const engineId = 'podman'; + const myImage = { + engineId, + Id: imageID, + Size: 0, + } as unknown as ImageInfo; + imagesInfos.set([myImage]); + + hasAuthMock.mockImplementation(() => { + return new Promise(() => false); + }); + + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + render(ImageDetails, { + imageID, + engineId, + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + }); + const summaryTab = screen.getByRole('link', { name: 'Summary' }); + expect(summaryTab).toBeInTheDocument(); + const checkTab = screen.getByRole('link', { name: 'Check' }); + expect(checkTab).toBeInTheDocument(); +}); diff --git a/packages/renderer/src/lib/image/ImageDetails.svelte b/packages/renderer/src/lib/image/ImageDetails.svelte index 8f1bb3f16a0f5..2353755629cb8 100644 --- a/packages/renderer/src/lib/image/ImageDetails.svelte +++ b/packages/renderer/src/lib/image/ImageDetails.svelte @@ -1,7 +1,7 @@ {#if image} @@ -71,6 +86,9 @@ onMount(() => { + {#if showCheckTab} + + {/if} @@ -82,6 +100,9 @@ onMount(() => { + + + {/if} diff --git a/packages/renderer/src/lib/image/ImageDetailsCheck.spec.ts b/packages/renderer/src/lib/image/ImageDetailsCheck.spec.ts new file mode 100644 index 0000000000000..c2bfe6d9e98c1 --- /dev/null +++ b/packages/renderer/src/lib/image/ImageDetailsCheck.spec.ts @@ -0,0 +1,281 @@ +import '@testing-library/jest-dom/vitest'; +import { beforeAll, beforeEach, expect, test, vi } from 'vitest'; +import ImageDetailsCheck from './ImageDetailsCheck.svelte'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import type { ImageChecks } from '@podman-desktop/api'; +import { imageCheckerProviders } from '/@/stores/image-checker-providers'; + +const getCancellableTokenSourceMock = vi.fn(); +const imageCheckMock = vi.fn(); +const cancelTokenSpy = vi.fn(); + +const tokenID = 70735; +beforeAll(() => { + (window as any).getCancellableTokenSource = getCancellableTokenSourceMock; + getCancellableTokenSourceMock.mockImplementation(() => tokenID); + (window as any).imageCheck = imageCheckMock; + (window as any).cancelToken = cancelTokenSpy; + (window as any).telemetryTrack = vi.fn(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('expect to display wait message before to receive results', async () => { + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + + imageCheckMock.mockImplementation(async () => { + // never returns results + return new Promise(() => {}); + }); + + render(ImageDetailsCheck, { + image: { + id: '123456', + shortId: '123', + name: 'an-image', + engineId: 'podman', + engineName: 'Podman', + tag: 'a-tag', + createdAt: 123, + age: '1 day', + humanSize: '1Mb', + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + selected: false, + inUse: false, + }, + }); + + vi.waitFor(() => { + const msg = screen.getByText(content => content.includes('Image analysis in progress')); + expect(msg).toBeInTheDocument(); + }); +}); + +test('expect to cancel when clicking the Cancel button', async () => { + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + + imageCheckMock.mockImplementation(async () => { + // never returns results + return new Promise(() => {}); + }); + + render(ImageDetailsCheck, { + image: { + id: '123456', + shortId: '123', + name: 'an-image', + engineId: 'podman', + engineName: 'Podman', + tag: 'a-tag', + createdAt: 123, + age: '1 day', + humanSize: '1Mb', + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + selected: false, + inUse: false, + }, + }); + + await vi.waitFor(async () => { + const abortBtn = screen.getByRole('button', { name: 'Cancel' }); + await fireEvent.click(abortBtn); + }); + + vi.waitFor(() => { + const msg = screen.getByText(content => content.includes('Image analysis canceled')); + expect(msg).toBeInTheDocument(); + }); + + expect(cancelTokenSpy).toHaveBeenCalledWith(tokenID); +}); + +test('expect to cancel when destroying the component', async () => { + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + + imageCheckMock.mockImplementation(async () => { + // never returns results + return new Promise(() => {}); + }); + + const result = render(ImageDetailsCheck, { + image: { + id: '123456', + shortId: '123', + name: 'an-image', + engineId: 'podman', + engineName: 'Podman', + tag: 'a-tag', + createdAt: 123, + age: '1 day', + humanSize: '1Mb', + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + selected: false, + inUse: false, + }, + }); + + await vi.waitFor(async () => { + screen.getByRole('button', { name: 'Cancel' }); + }); + + result.unmount(); + + expect(cancelTokenSpy).toHaveBeenCalledWith(tokenID); +}); + +test('expect to not cancel again when destroying the component after manual cancel', async () => { + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + + imageCheckMock.mockImplementation(async () => { + // never returns results + return new Promise(() => {}); + }); + + const result = render(ImageDetailsCheck, { + image: { + id: '123456', + shortId: '123', + name: 'an-image', + engineId: 'podman', + engineName: 'Podman', + tag: 'a-tag', + createdAt: 123, + age: '1 day', + humanSize: '1Mb', + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + selected: false, + inUse: false, + }, + }); + + await vi.waitFor(async () => { + const abortBtn = screen.getByRole('button', { name: 'Cancel' }); + await fireEvent.click(abortBtn); + }); + + vi.waitFor(() => { + const msg = screen.getByText(content => content.includes('Image analysis canceled')); + expect(msg).toBeInTheDocument(); + }); + + expect(cancelTokenSpy).toHaveBeenCalledWith(tokenID); + + result.unmount(); + + expect(cancelTokenSpy).toHaveBeenCalledTimes(1); +}); + +test('expect to display results from image checker provider', async () => { + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + + imageCheckMock.mockResolvedValue({ + checks: [ + { + name: 'check1', + status: 'failed', + markdownDescription: 'an error for check1', + severity: 'critical', + }, + ], + } as ImageChecks); + + render(ImageDetailsCheck, { + image: { + id: '123456', + shortId: '123', + name: 'an-image', + engineId: 'podman', + engineName: 'Podman', + tag: 'a-tag', + createdAt: 123, + age: '1 day', + humanSize: '1Mb', + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + selected: false, + inUse: false, + }, + }); + + vi.waitFor(() => { + const msg = screen.getByText(content => content.includes('Image analysis complete')); + expect(msg).toBeInTheDocument(); + }); + + vi.waitFor(() => { + const cell = screen.getByText('check1'); + expect(cell).toBeInTheDocument(); + }); +}); + +test('expect to not cancel when destroying the component after displaying results from image checker provider', async () => { + imageCheckerProviders.set([ + { + id: 'provider1', + label: 'Image Checker', + }, + ]); + + imageCheckMock.mockResolvedValue({ + checks: [ + { + name: 'check1', + status: 'failed', + markdownDescription: 'an error for check1', + severity: 'critical', + }, + ], + } as ImageChecks); + + const result = render(ImageDetailsCheck, { + image: { + id: '123456', + shortId: '123', + name: 'an-image', + engineId: 'podman', + engineName: 'Podman', + tag: 'a-tag', + createdAt: 123, + age: '1 day', + humanSize: '1Mb', + base64RepoTag: Buffer.from('', 'binary').toString('base64'), + selected: false, + inUse: false, + }, + }); + + vi.waitFor(() => { + const cell = screen.getByText('check1'); + expect(cell).toBeInTheDocument(); + }); + + result.unmount(); + + expect(cancelTokenSpy).not.toHaveBeenCalled(); +}); diff --git a/packages/renderer/src/lib/image/ImageDetailsCheck.svelte b/packages/renderer/src/lib/image/ImageDetailsCheck.svelte new file mode 100644 index 0000000000000..2f215730c677c --- /dev/null +++ b/packages/renderer/src/lib/image/ImageDetailsCheck.svelte @@ -0,0 +1,131 @@ + + + +
+
+ + {#if remainingProviders > 0} + Image analysis in progress... + {:else if aborted} + Image analysis canceled + {:else} + Image analysis complete + {/if} +
+ {#if remainingProviders > 0} +
+ +
+ {/if} +
+
diff --git a/packages/renderer/src/lib/ui/ProviderResultPage.svelte b/packages/renderer/src/lib/ui/ProviderResultPage.svelte new file mode 100644 index 0000000000000..3e56cda50496c --- /dev/null +++ b/packages/renderer/src/lib/ui/ProviderResultPage.svelte @@ -0,0 +1,94 @@ + + +
+
+ +
+
Checkers
+
+
+ {#each providers as provider} +
+
+ {provider.info.label} + {#if provider.state === 'running'} + + {/if} + {#if provider.state === 'failed'} + + + + {/if} +
+ {#if provider.error} +
{provider.error}
+ {/if} +
+ {/each} +
+
+ {#each results as result} +
+
+ + +
{result.check.name}
+
Reported by {result.provider.label}
+
+ {#if result.check.markdownDescription} +
{result.check.markdownDescription}
+ {/if} +
+ {/each} +
+
+
diff --git a/packages/renderer/src/lib/ui/ProviderResultPage.ts b/packages/renderer/src/lib/ui/ProviderResultPage.ts new file mode 100644 index 0000000000000..3a5f887e5891c --- /dev/null +++ b/packages/renderer/src/lib/ui/ProviderResultPage.ts @@ -0,0 +1,13 @@ +import type { ImageCheck } from '@podman-desktop/api'; +import type { ImageCheckerInfo } from '../../../../main/src/plugin/api/image-checker-info'; + +export interface ProviderUI { + info: ImageCheckerInfo; + state: 'running' | 'success' | 'failed'; + error?: Error; +} + +export interface CheckUI { + provider: ImageCheckerInfo; + check: ImageCheck; +} diff --git a/packages/renderer/src/stores/image-checker-providers.ts b/packages/renderer/src/stores/image-checker-providers.ts new file mode 100644 index 0000000000000..a96b31105a469 --- /dev/null +++ b/packages/renderer/src/stores/image-checker-providers.ts @@ -0,0 +1,51 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { writable, type Writable } from 'svelte/store'; +import type { ImageCheckerInfo } from '../../../main/src/plugin/api/image-checker-info'; +import { EventStore } from './event-store'; + +const windowEvents = ['image-checker-provider-update', 'image-checker-provider-remove']; +const windowListeners = ['extensions-already-started']; + +let readyToUpdate = false; + +export async function checkForUpdate(eventName: string): Promise { + if ('extensions-already-started' === eventName) { + readyToUpdate = true; + } + + // do not fetch until extensions are all started + return readyToUpdate; +} + +export const imageCheckerProviders: Writable = writable([]); + +const getImageCheckerProvidersInfo = (): Promise => { + return window.getImageCheckerProviders(); +}; + +const eventStore = new EventStore( + 'image checker providers', + imageCheckerProviders, + checkForUpdate, + windowEvents, + windowListeners, + getImageCheckerProvidersInfo, +); +eventStore.setup();