Skip to content

Commit

Permalink
feat: adds log page for builds (#792)
Browse files Browse the repository at this point in the history
* 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
cdrage authored Sep 11, 2024
1 parent 20999e6 commit 096be24
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 29 deletions.
2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 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,6 +251,27 @@ export class BootcApiImpl implements BootcApi {
return getUidGid();
}

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 getConfigurationValue(config: string, section: string): Promise<unknown> {
try {
return podmanDesktopApi.configuration.getConfiguration(config).get(section);
} catch (err) {
console.error('Error getting configuration, will return undefined: ', 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
Expand Down
6 changes: 6 additions & 0 deletions packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -35,6 +36,11 @@ onMount(() => {
<Route path="/build" breadcrumb="Build">
<Build />
</Route>
<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)} />
</Route>
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(),
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();
});
136 changes: 136 additions & 0 deletions packages/frontend/src/Logs.svelte
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>
10 changes: 10 additions & 0 deletions packages/frontend/src/lib/BootcActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
12 changes: 11 additions & 1 deletion packages/frontend/src/lib/BootcActions.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts">
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import ListItemButtonIcon from './upstream/ListItemButtonIcon.svelte';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { faFileAlt, faTrash } from '@fortawesome/free-solid-svg-icons';
import { router } from 'tinro';
import { bootcClient } from '../api/client';
export let object: BootcBuildInfo;
Expand All @@ -11,6 +12,15 @@ export let detailed = false;
async function deleteBuild(): Promise<void> {
await bootcClient.deleteBuilds([object]);
}
// Navigate to the build
async function gotoLogs(): Promise<void> {
// Convert object.folder to base64
const base64FolderLocation = btoa(object.folder);
const base64BuildImageName = btoa(object.image);
router.goto(`/logs/${base64BuildImageName}/${base64FolderLocation}`);
}
</script>

<ListItemButtonIcon title="Build Logs" onClick={() => gotoLogs()} detailed={detailed} icon={faFileAlt} />
<ListItemButtonIcon title="Delete Build" onClick={() => deleteBuild()} detailed={detailed} icon={faTrash} />
Loading

0 comments on commit 096be24

Please sign in to comment.