Skip to content

Commit

Permalink
feat: adds log page for builds
Browse files Browse the repository at this point in the history
### 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
cdrage committed Sep 5, 2024
1 parent d78a1be commit bc29815
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 120 deletions.
28 changes: 25 additions & 3 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -249,9 +251,29 @@ export class BootcApiImpl implements BootcApi {
return getUidGid();
}

async createFileSystemWatcher(folder:string): Promise<podmanDesktopApi.FileSystemWatcher> {
console.log('going to do stuff!');
return podmanDesktopApi.fs.createFileSystemWatcher(folder);
async loadLogsFromFolder(folder: string): Promise<string> {
// 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<unknown> {
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
Expand Down
6 changes: 4 additions & 2 deletions packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ onMount(() => {
<Route path="/build" breadcrumb="Build">
<Build />
</Route>
<Route path="/logs/:base64FolderLocation" breadcrumb="Logs" let:meta>
<Logs base64FolderLocation={meta.params.base64FolderLocation} />
<Route path="/logs/:base64BuildImageName/:base64FolderLocation" breadcrumb="Logs" let:meta>
<Logs
base64BuildImageName={meta.params.base64BuildImageName}
base64FolderLocation={meta.params.base64FolderLocation} />
</Route>
<Route path="/build/:name/:tag" breadcrumb="Build" let:meta>
<Build imageName={decodeURIComponent(meta.params.name)} imageTag={decodeURIComponent(meta.params.tag)} />
Expand Down
88 changes: 88 additions & 0 deletions packages/frontend/src/Logs.spec.ts
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();
});
225 changes: 116 additions & 109 deletions packages/frontend/src/Logs.svelte
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>
Loading

0 comments on commit bc29815

Please sign in to comment.