From 22944a933133e529245a3613963f841bc09b387a Mon Sep 17 00:00:00 2001 From: Charlie Drage Date: Thu, 5 Sep 2024 15:43:57 -0400 Subject: [PATCH] feat: adds log page for builds ### What does this PR do? * Adds a log action button that will show the logs in real-time in a separate page * Automatically refreshes / appends to the output * Uses terminal settings from Podman Desktop to match what the user has setup. ### Screenshot / video of UI ### What issues does this PR fix or reference? Closes https://github.com/containers/podman-desktop-extension-bootc/issues/677 ### How to test this PR? 1. Start a build 2. Click the "logs" button on the dashboard 3. Watch logs propagate Signed-off-by: Charlie Drage --- packages/backend/package.json | 2 + packages/backend/src/api-impl.ts | 27 ++++ packages/frontend/src/App.svelte | 6 + packages/frontend/src/Logs.spec.ts | 88 ++++++++++++ packages/frontend/src/Logs.svelte | 136 ++++++++++++++++++ .../frontend/src/lib/BootcActions.spec.ts | 10 ++ packages/frontend/src/lib/BootcActions.svelte | 12 +- .../src/lib/upstream/terminal-theme.ts | 81 +++++++++++ packages/shared/src/BootcAPI.ts | 2 + yarn.lock | 41 ++---- 10 files changed, 376 insertions(+), 29 deletions(-) create mode 100644 packages/frontend/src/Logs.spec.ts create mode 100644 packages/frontend/src/Logs.svelte create mode 100644 packages/frontend/src/lib/upstream/terminal-theme.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 22ed6988..f8955365 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -100,6 +100,8 @@ "@typescript-eslint/eslint-plugin": "^8.5.0", "@typescript-eslint/parser": "^6.16.0", "@vitest/coverage-v8": "^2.0.2", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", "eslint": "^8.56.0", "eslint-import-resolver-custom-alias": "^1.3.2", "eslint-import-resolver-typescript": "^3.6.3", diff --git a/packages/backend/src/api-impl.ts b/packages/backend/src/api-impl.ts index 0e87cddd..d6aa3479 100644 --- a/packages/backend/src/api-impl.ts +++ b/packages/backend/src/api-impl.ts @@ -26,6 +26,8 @@ import * as containerUtils from './container-utils'; import { Messages } from '/@shared/src/messages/Messages'; import { telemetryLogger } from './extension'; import { checkPrereqs, isLinux, getUidGid } from './machine-utils'; +import * as fs from 'node:fs'; +import path from 'node:path'; import { getContainerEngine } from './container-utils'; export class BootcApiImpl implements BootcApi { @@ -249,6 +251,31 @@ export class BootcApiImpl implements BootcApi { return getUidGid(); } + async loadLogsFromFolder(folder: string): Promise { + // Combine folder name and image-build.log + const filePath = path.join(folder, 'image-build.log'); + + // Simply try to the read the file and return the contents, must use utf8 formatting + // to ensure the file is read properly / no ascii characters. + return fs.readFileSync(filePath, 'utf8'); + } + + // Get configuration values from Podman Desktop + // specifically we do this so we can obtain the setting for terminal font size + // returns "any" because the configuration values are not typed + async getUserConfigurationValue(config: string, section: string): Promise { + try { + console.log('going to try and get configuration value: ', config); + const value = podmanDesktopApi.configuration.getConfiguration(config).get(section); + console.log('Configuration value:getConfiguration', value); + return value; + } catch (err) { + await podmanDesktopApi.window.showErrorMessage(`Error getting configuration: ${err}`); + console.error('Error getting configuration: ', err); + } + return undefined; + } + // The API does not allow callbacks through the RPC, so instead // we send "notify" messages to the frontend to trigger a refresh // this method is internal and meant to be used by the API implementation diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index bee0bbe6..eda7e464 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -9,6 +9,7 @@ import { getRouterState } from './api/client'; import Homepage from './Homepage.svelte'; import { rpcBrowser } from '/@/api/client'; import { Messages } from '/@shared/src/messages/Messages'; +import Logs from './Logs.svelte'; router.mode.hash(); @@ -35,6 +36,11 @@ onMount(() => { + + + diff --git a/packages/frontend/src/Logs.spec.ts b/packages/frontend/src/Logs.spec.ts new file mode 100644 index 00000000..f86260cd --- /dev/null +++ b/packages/frontend/src/Logs.spec.ts @@ -0,0 +1,88 @@ +/********************************************************************** + * Copyright (C) 2024 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 { render, screen, waitFor } from '@testing-library/svelte'; +import { vi, test, expect, beforeAll } from 'vitest'; +import Logs from './Logs.svelte'; +import { bootcClient } from './api/client'; + +vi.mock('./api/client', async () => ({ + bootcClient: { + loadLogsFromFolder: vi.fn(), + getUserConfigurationValue: vi.fn(), + }, +})); + +beforeAll(() => { + (window as any).ResizeObserver = ResizeObserver; + (window as any).getConfigurationValue = vi.fn().mockResolvedValue(undefined); + (window as any).matchMedia = vi.fn().mockReturnValue({ + addListener: vi.fn(), + }); + + Object.defineProperty(window, 'matchMedia', { + value: () => { + return { + matches: false, + addListener: () => {}, + removeListener: () => {}, + }; + }, + }); +}); + +class ResizeObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} + +const mockLogs = `Build log line 1 +Build log line 2 +Build log line 3`; + +test('Render logs and terminal setup', async () => { + vi.mocked(bootcClient.loadLogsFromFolder).mockResolvedValue(mockLogs); + vi.mocked(bootcClient.getUserConfigurationValue).mockResolvedValue(14); + + const base64FolderLocation = btoa('/path/to/logs'); + const base64BuildImageName = btoa('test-image'); + + render(Logs, { base64FolderLocation, base64BuildImageName }); + + // Wait for the logs to be shown + await waitFor(() => { + expect(bootcClient.loadLogsFromFolder).toHaveBeenCalledWith('/path/to/logs'); + expect(screen.queryByText('Build log line 1')).toBeDefined(); + expect(screen.queryByText('Build log line 2')).toBeDefined(); + expect(screen.queryByText('Build log line 3')).toBeDefined(); + }); +}); + +test('Handles empty logs correctly', async () => { + vi.mocked(bootcClient.loadLogsFromFolder).mockResolvedValue(''); + vi.mocked(bootcClient.getUserConfigurationValue).mockResolvedValue(14); + + const base64FolderLocation = btoa('/empty/logs'); + const base64BuildImageName = btoa('empty-image'); + + render(Logs, { base64FolderLocation, base64BuildImageName }); + + // Verify no logs message is displayed when logs are empty + const emptyMessage = await screen.findByText(/Unable to read image-build.log file from \/empty\/logs/); + expect(emptyMessage).toBeDefined(); +}); diff --git a/packages/frontend/src/Logs.svelte b/packages/frontend/src/Logs.svelte new file mode 100644 index 00000000..d32acbcf --- /dev/null +++ b/packages/frontend/src/Logs.svelte @@ -0,0 +1,136 @@ + + + + + + + diff --git a/packages/frontend/src/lib/BootcActions.spec.ts b/packages/frontend/src/lib/BootcActions.spec.ts index 0d0a65c5..b97cd13a 100644 --- a/packages/frontend/src/lib/BootcActions.spec.ts +++ b/packages/frontend/src/lib/BootcActions.spec.ts @@ -70,3 +70,13 @@ test('Test clicking on delete button', async () => { expect(spyOnDelete).toHaveBeenCalled(); }); + +test('Test clicking on logs button', async () => { + render(BootcActions, { object: mockHistoryInfo }); + + // Click on logs button + const logsButton = screen.getAllByRole('button', { name: 'Build Logs' })[0]; + logsButton.click(); + + expect(window.location.href).toContain('/logs'); +}); diff --git a/packages/frontend/src/lib/BootcActions.svelte b/packages/frontend/src/lib/BootcActions.svelte index 0860bf25..fded053d 100644 --- a/packages/frontend/src/lib/BootcActions.svelte +++ b/packages/frontend/src/lib/BootcActions.svelte @@ -1,7 +1,8 @@ + gotoLogs()} detailed={detailed} icon={faFileAlt} /> deleteBuild()} detailed={detailed} icon={faTrash} /> diff --git a/packages/frontend/src/lib/upstream/terminal-theme.ts b/packages/frontend/src/lib/upstream/terminal-theme.ts new file mode 100644 index 00000000..e2e407f9 --- /dev/null +++ b/packages/frontend/src/lib/upstream/terminal-theme.ts @@ -0,0 +1,81 @@ +/********************************************************************** + * Copyright (C) 2024 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 type { ITheme } from '@xterm/xterm'; + +// Array of strings to extract from the CSS variables +// we list it here as we cannot infer the properties from the ITheme type at runtime. Another reason is to avoid +// the conflict with the 'extendedAnsi' string[] array key. We also provide "safer" method of gathering the CSS values. +const KNOWN_THEME_PROPERTIES = [ + 'foreground', + 'background', + 'cursor', + 'selectionBackground', + 'selectionForeground', + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite', +] as const; + +const TERMINAL_PREFIX = '--pd-terminal-'; + +// Utility function to get the terminal theme from CSS variables supplied by color-registry +// we do this by getting the computed style of the root element and extracting the values of the variables +// that start with the prefix '--pd-terminal-' +// we then go through all known theme properties of ITheme and assign the values to the theme object +export function getTerminalTheme(): ITheme | undefined { + const root = document.documentElement; + + if (!root) { + console.error( + 'Could not find document.documentElement and was unable to load terminal theme, returning undefined / default theme', + ); + return undefined; + } + + // Get the computed style of the root element containing the color-registry variables + const computedStyle = window.getComputedStyle(root); + const theme: ITheme = {} as ITheme; + + // Extract and assign each property to the theme object from the CSS variables + KNOWN_THEME_PROPERTIES.forEach(property => { + const cssVar = `${TERMINAL_PREFIX}${property}`; + + // Find the property value, trim it and assign it to the theme object + // only if it is not an empty string + const propertyValue = computedStyle.getPropertyValue(cssVar).trim(); + if (propertyValue) { + theme[property] = propertyValue; + } + }); + + return theme; +} diff --git a/packages/shared/src/BootcAPI.ts b/packages/shared/src/BootcAPI.ts index 5096082d..b936dda9 100644 --- a/packages/shared/src/BootcAPI.ts +++ b/packages/shared/src/BootcAPI.ts @@ -37,6 +37,8 @@ export abstract class BootcApi { abstract openLink(link: string): Promise; abstract isLinux(): Promise; abstract getUidGid(): Promise; + abstract loadLogsFromFolder(folder: string): Promise; + abstract getUserConfigurationValue(config: string, section: string): Promise; abstract telemetryLogUsage(eventName: string, data?: Record | undefined): Promise; abstract telemetryLogError(eventName: string, data?: Record | undefined): Promise; } diff --git a/yarn.lock b/yarn.lock index b09df37c..fbcba272 100644 --- a/yarn.lock +++ b/yarn.lock @@ -969,6 +969,16 @@ loupe "^3.1.1" tinyrainbow "^1.2.0" +"@xterm/addon-fit@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz#bebf87fadd74e3af30fdcdeef47030e2592c6f55" + integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== + +"@xterm/xterm@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" + integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -3736,16 +3746,7 @@ std-env@^3.7.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, string-width@^5.1.2: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, string-width@^5.1.2: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3782,14 +3783,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4353,16 +4347,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0, wrap-ansi@^8.1.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0, wrap-ansi@^8.1.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==