-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: adds log page for builds (#792)
* 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 <!-- If this PR is changing UI, please include screenshots or screencasts showing the difference --> ### What issues does this PR fix or reference? <!-- Include any related issues from Podman Desktop repository (or from another issue tracker). --> Closes #677 ### How to test this PR? 1. Start a build 2. Click the "logs" button on the dashboard 3. Watch logs propagate <!-- Please explain steps to reproduce --> Signed-off-by: Charlie Drage <charlie@charliedrage.com> * update based on review Signed-off-by: Charlie Drage <charlie@charliedrage.com> --------- Signed-off-by: Charlie Drage <charlie@charliedrage.com>
- Loading branch information
Showing
10 changed files
with
372 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(), | ||
getConfigurationValue: 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.getConfigurationValue).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.getConfigurationValue).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(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
<script lang="ts"> | ||
import '@xterm/xterm/css/xterm.css'; | ||
import { DetailsPage, EmptyScreen, FormPage } from '@podman-desktop/ui-svelte'; | ||
import { FitAddon } from '@xterm/addon-fit'; | ||
import { Terminal } from '@xterm/xterm'; | ||
import { onDestroy, onMount } from 'svelte'; | ||
import { router } from 'tinro'; | ||
import DiskImageIcon from './lib/DiskImageIcon.svelte'; | ||
import { bootcClient } from './api/client'; | ||
import { getTerminalTheme } from './lib/upstream/terminal-theme'; | ||
export let base64FolderLocation: string; | ||
export let base64BuildImageName: string; | ||
// Decode the base64 folder location to a normal string path | ||
const folderLocation = atob(base64FolderLocation); | ||
const buildImageName = atob(base64BuildImageName); | ||
// Log | ||
let logsXtermDiv: HTMLDivElement; | ||
let noLogs = true; | ||
let previousLogs: string = ''; | ||
const refreshInterval = 2000; | ||
// Terminal resize | ||
let resizeObserver: ResizeObserver; | ||
let termFit: FitAddon; | ||
let logsTerminal: Terminal; | ||
let logInterval: NodeJS.Timeout; | ||
async function fetchFolderLogs() { | ||
const logs = await bootcClient.loadLogsFromFolder(folderLocation); | ||
// We will write only the new logs to the terminal, | ||
// this is a simple way of updating the logs as we update it by calling the function | ||
// every 2 seconds instead of setting up a file watcher (unable to do so through RPC calls, due to long-running process) | ||
if (logs !== previousLogs) { | ||
// Write only the new logs to the log | ||
const newLogs = logs.slice(previousLogs.length); | ||
logsTerminal.write(newLogs); | ||
previousLogs = logs; // Update the stored logs | ||
noLogs = false; // Make sure that the logs are visible | ||
} | ||
} | ||
async function refreshTerminal() { | ||
// missing element, return | ||
if (!logsXtermDiv) { | ||
console.log('missing xterm div, exiting...'); | ||
return; | ||
} | ||
// Retrieve the user configuration settings for the terminal to match the rest of Podman Desktop. | ||
const fontSize = (await bootcClient.getConfigurationValue('terminal', 'integrated.fontSize')) as number; | ||
const lineHeight = (await bootcClient.getConfigurationValue('terminal', 'integrated.lineHeight')) as number; | ||
logsTerminal = new Terminal({ | ||
fontSize: fontSize, | ||
lineHeight: lineHeight, | ||
disableStdin: true, | ||
theme: getTerminalTheme(), | ||
convertEol: true, | ||
}); | ||
termFit = new FitAddon(); | ||
logsTerminal.loadAddon(termFit); | ||
logsTerminal.open(logsXtermDiv); | ||
// Disable cursor as we are just reading the logs | ||
logsTerminal.write('\x1b[?25l'); | ||
// Call fit addon each time we resize the window | ||
window.addEventListener('resize', () => { | ||
termFit.fit(); | ||
}); | ||
termFit.fit(); | ||
} | ||
onMount(async () => { | ||
// Refresh the terminal on initial load | ||
await refreshTerminal(); | ||
// Fetch logs initially and set up the interval to run every 2 seconds | ||
// we do this to avoid having to setup a file watcher since long-running commands to the backend is | ||
// not possible through RPC calls (yet). | ||
fetchFolderLogs(); | ||
logInterval = setInterval(fetchFolderLogs, refreshInterval); | ||
// Resize the terminal each time we change the div size | ||
resizeObserver = new ResizeObserver(() => { | ||
termFit?.fit(); | ||
}); | ||
// Observe the terminal div | ||
resizeObserver.observe(logsXtermDiv); | ||
}); | ||
onDestroy(() => { | ||
// Cleanup the observer on destroy | ||
resizeObserver?.unobserve(logsXtermDiv); | ||
// Clear the interval when the component is destroyed | ||
clearInterval(logInterval); | ||
}); | ||
export function goToHomePage(): void { | ||
router.goto('/'); | ||
} | ||
</script> | ||
|
||
<DetailsPage | ||
title="{buildImageName} build logs" | ||
breadcrumbLeftPart="Bootable Containers" | ||
breadcrumbRightPart="{buildImageName} build logs" | ||
breadcrumbTitle="Go back to homepage" | ||
onclose={goToHomePage} | ||
onbreadcrumbClick={goToHomePage}> | ||
<DiskImageIcon slot="icon" size="30px" /> | ||
<svelte:fragment slot="content"> | ||
<EmptyScreen | ||
icon={undefined} | ||
title="No log file" | ||
message="Unable to read image-build.log file from {folderLocation}" | ||
hidden={noLogs === false} /> | ||
|
||
<div | ||
class="min-w-full flex flex-col" | ||
class:invisible={noLogs === true} | ||
class:h-0={noLogs === true} | ||
class:h-full={noLogs === false} | ||
bind:this={logsXtermDiv}> | ||
</div> | ||
</svelte:fragment> | ||
</DetailsPage> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.