forked from podman-desktop/extension-bootc
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
### 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 podman-desktop#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 <[email protected]>
- Loading branch information
Showing
8 changed files
with
331 additions
and
120 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
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(), | ||
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(); | ||
}); |
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 |
---|---|---|
@@ -1,129 +1,136 @@ | ||
<script lang="ts"> | ||
import '@xterm/xterm/css/xterm.css'; | ||
import { EmptyScreen, FormPage } from '@podman-desktop/ui-svelte'; | ||
import { FitAddon } from '@xterm/addon-fit'; | ||
import { Terminal } from '@xterm/xterm'; | ||
import { onDestroy, onMount } from 'svelte'; | ||
import type { FileSystemWatcher } from '@podman-desktop/api' | ||
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 { TerminalSettings } from '../../../../main/src/plugin/terminal-settings'; | ||
import { getTerminalTheme } from '../../../../main/src/plugin/terminal-theme'; | ||
import { isMultiplexedLog } from '../stream/stream-utils'; | ||
import NoLogIcon from '../ui/NoLogIcon.svelte'; | ||
*/ | ||
export let base64FolderLocation: string; | ||
// Decode the base64 folder location to a normal string path | ||
const folderLocation = atob(base64FolderLocation); | ||
// Log | ||
let logsXtermDiv: HTMLDivElement; | ||
let noLogs = true; | ||
let fsWatcher: FileSystemWatcher; | ||
// Terminal resize | ||
let resizeObserver: ResizeObserver; | ||
let termFit: FitAddon; | ||
let logsTerminal: Terminal; | ||
async function fetchFolderLogs() { | ||
console.log('Fetching logs from', folderLocation); | ||
} | ||
async function refreshTerminal() { | ||
// missing element, return | ||
if (!logsXtermDiv) { | ||
console.log('missing xterm div, exiting...'); | ||
return; | ||
} | ||
/* | ||
const fontSize = await window.getConfigurationValue<number>( | ||
TerminalSettings.SectionName + '.' + TerminalSettings.FontSize, | ||
); | ||
const lineHeight = await window.getConfigurationValue<number>( | ||
TerminalSettings.SectionName + '.' + TerminalSettings.LineHeight, | ||
); | ||
*/ | ||
logsTerminal = new Terminal({ | ||
disableStdin: true, | ||
//theme: getTerminalTheme(), | ||
convertEol: true, | ||
}); | ||
termFit = new FitAddon(); | ||
logsTerminal.loadAddon(termFit); | ||
logsTerminal.open(logsXtermDiv); | ||
// Disable cursor | ||
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(); | ||
fetchFolderLogs(); | ||
// Watch for changes in the folder, and fetchFolderLogs again if there are any changes. | ||
fsWatcher = await bootcClient.createFileSystemWatcher(folderLocation); | ||
fsWatcher.onDidChange(() => { | ||
fetchFolderLogs(); | ||
}); | ||
// 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); | ||
}); | ||
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.getUserConfigurationValue('terminal', 'integrated.fontSize')) as number; | ||
const lineHeight = (await bootcClient.getUserConfigurationValue('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> | ||
<FormPage | ||
title="Build Logs" | ||
</script> | ||
|
||
<DetailsPage | ||
title="{buildImageName} build logs" | ||
breadcrumbLeftPart="Bootable Containers" | ||
breadcrumbRightPart="Build Logs" | ||
breadcrumbRightPart="{buildImageName} build logs" | ||
breadcrumbTitle="Go back to homepage" | ||
onclose={goToHomePage} | ||
onbreadcrumbClick={goToHomePage}> | ||
|
||
<DiskImageIcon slot="icon" size="30px" /> | ||
<div slot="content" class="p-5 min-w-full h-fit"> | ||
<svelte:fragment slot="content"> | ||
<EmptyScreen | ||
icon={undefined} | ||
title="No log file" | ||
message="Unable to read image-build.log file from {folderLocation}" | ||
hidden={noLogs === false} /> | ||
|
||
<EmptyScreen icon={undefined} title="No Log" message="No log output 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> | ||
|
||
</div> | ||
</FormPage> | ||
</svelte:fragment> | ||
</DetailsPage> |
Oops, something went wrong.