Skip to content

Commit

Permalink
feat: Add gather & download logs button in troubleshooting
Browse files Browse the repository at this point in the history
### What does this PR do?

* Adds a button to collect logs which is currently only hardcoded to
  retrieve the console logs
* Adds a button to download the logs as a zip file with all logs stored
  within as a .txt file
* Shows which files have been generated and what will be zipped.

### 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 #5048

### How to test this PR?

<!-- Please explain steps to reproduce -->

1. Go to troubleshooting
2. Click the collect logs & download logs buttons.

Signed-off-by: Charlie Drage <charlie@charliedrage.com>
  • Loading branch information
cdrage committed Feb 6, 2024
1 parent 8b13cb0 commit a88ff0b
Show file tree
Hide file tree
Showing 7 changed files with 442 additions and 0 deletions.
16 changes: 16 additions & 0 deletions packages/main/src/mainWindow.ts
Expand Up @@ -131,6 +131,22 @@ async function createWindow(): Promise<BrowserWindow> {
});
});

ipcMain.on('dialog:saveFile', (_, param: { dialogId: string; message: string; defaultPath: string }) => {
dialog
.showSaveDialog(browserWindow, {
title: param.message,
defaultPath: param.defaultPath,
})
.then(response => {
if (!response.canceled && response.filePath) {
browserWindow.webContents.send('dialog:open-file-or-folder-response', param.dialogId, response);
}
})
.catch((err: unknown) => {
console.error('Error saving file', err);
});
});

let configurationRegistry: ConfigurationRegistry;
ipcMain.on('configuration-registry', (_, data) => {
configurationRegistry = data;
Expand Down
14 changes: 14 additions & 0 deletions packages/main/src/plugin/index.ts
Expand Up @@ -148,6 +148,7 @@ import { ImageCheckerImpl } from './image-checker.js';
import type { ImageCheckerInfo } from './api/image-checker-info.js';
import { AppearanceInit } from './appearance-init.js';
import type { KubeContext } from './kubernetes-context.js';
import { Troubleshooting } from './troubleshooting.js';
import { KubernetesInformerManager } from './kubernetes-informer-registry.js';
import type { KubernetesInformerResourcesType } from './api/kubernetes-informer-info.js';
import { OpenDevToolsInit } from './open-devtools-init.js';
Expand Down Expand Up @@ -742,6 +743,8 @@ export class PluginSystem {

const imageChecker = new ImageCheckerImpl(apiSender);

const troubleshooting = new Troubleshooting(apiSender);

const contributionManager = new ContributionManager(apiSender, directories, containerProviderRegistry, exec);

const navigationManager = new NavigationManager(apiSender, containerProviderRegistry, contributionManager);
Expand Down Expand Up @@ -1279,6 +1282,17 @@ export class PluginSystem {
return cliToolRegistry.getCliToolInfos();
});

this.ipcHandle(
'troubleshooting:saveLogs',
async (
_listener,
consoleLogs: { logType: LogType; message: string }[],
destinaton: string,
): Promise<string[]> => {
return troubleshooting.saveLogs(consoleLogs, destinaton);
},
);

this.ipcHandle(
'cli-tool-registry:updateCliTool',
async (_listener, id: string, loggerId: string): Promise<void> => {
Expand Down
204 changes: 204 additions & 0 deletions packages/main/src/plugin/troubleshooting.spec.ts
@@ -0,0 +1,204 @@
/**********************************************************************
* Copyright (C) 2023 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 { beforeEach, expect, test, vi } from 'vitest';
import { Troubleshooting, generateLogFileName } from './troubleshooting.js';
import type { FileMap, LogType } from './troubleshooting.js';
import * as fs from 'node:fs';

const writeZipMock = vi.fn();
const addFileMock = vi.fn();

vi.mock('electron', () => {
return {
ipcMain: {
emit: vi.fn(),
on: vi.fn(),
},
};
});

vi.mock('adm-zip', () => {
return {
default: class {
addFile = addFileMock;
writeZip = writeZipMock;
},
};
});

beforeEach(() => {
vi.clearAllMocks();
});

// Test the saveLogsToZip function
test('Should save a zip file with the correct content', async () => {
const zipFile = new Troubleshooting(undefined);
const fileMaps = [
{
fileName: 'file1',
content: 'content1',
},
{
fileName: 'file2',
content: 'content2',
},
];

const zipSpy = vi.spyOn(zipFile, 'saveLogsToZip');

await zipFile.saveLogsToZip(fileMaps, 'test.zip');
expect(zipSpy).toHaveBeenCalledWith(fileMaps, 'test.zip');

expect(writeZipMock).toHaveBeenCalledWith('test.zip');
});

// Do not expect writeZipMock to have been called if fileMaps is empty
test('Should not save a zip file if fileMaps is empty', async () => {
const zipFile = new Troubleshooting(undefined);
const fileMaps: FileMap[] = [];

const zipSpy = vi.spyOn(zipFile, 'saveLogsToZip');

await zipFile.saveLogsToZip(fileMaps, 'test.zip');
expect(zipSpy).toHaveBeenCalledWith(fileMaps, 'test.zip');

expect(writeZipMock).not.toHaveBeenCalled();
});

// Expect the file name to have a .txt extension
test('Should have a .txt extension in the file name', async () => {
const zipFile = new Troubleshooting(undefined);
const fileMaps = [
{
fileName: 'file1',
content: 'content1',
},
{
fileName: 'file2',
content: 'content2',
},
];

const zipSpy = vi.spyOn(zipFile, 'saveLogsToZip');

await zipFile.saveLogsToZip(fileMaps, 'test.zip');
expect(zipSpy).toHaveBeenCalledWith(fileMaps, 'test.zip');

expect(addFileMock).toHaveBeenCalledWith('file1.txt', Buffer.from('content1', 'utf8'));
expect(addFileMock).toHaveBeenCalledWith('file2.txt', Buffer.from('content2', 'utf8'));
});

// Expect getConsoleLogs to correctly format the console logs passed in
test('Should correctly format console logs', async () => {
const zipFile = new Troubleshooting(undefined);
const consoleLogs = [
{
logType: 'log' as LogType,
date: new Date(),
message: 'message1',
},
{
logType: 'log' as LogType,
date: new Date(),
message: 'message2',
},
];

const zipSpy = vi.spyOn(zipFile, 'getConsoleLogs');

const fileMaps = zipFile.getConsoleLogs(consoleLogs);
expect(zipSpy).toHaveBeenCalledWith(consoleLogs);

expect(fileMaps[0].fileName).toContain('console');
expect(fileMaps[0].content).toContain('log : message1');
expect(fileMaps[0].content).toContain('log : message2');
});

// Expect getSystemLogs to return getMacSystemLogs if the platform is darwin
// mock the private getMacSystemLogs function so we can spy on it
test('Should return getMacSystemLogs if the platform is darwin', async () => {
// Mock platform to be darwin
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');

const readFileMock = vi.spyOn(fs.promises, 'readFile');
readFileMock.mockResolvedValue('content');

// Mock exists to be true
vi.mock('node:fs');
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return true;
});

const zipFile = new Troubleshooting(undefined);
const getSystemLogsSpy = vi.spyOn(zipFile, 'getSystemLogs');

await zipFile.getSystemLogs();
expect(getSystemLogsSpy).toHaveBeenCalled();

// Expect it to have been called twice as it checked stdout and stderr
expect(readFileMock).toHaveBeenCalledTimes(2);

// Expect readFileMock to have been called with /Library/Logs/Podman Desktop/launchd-stdout.log but CONTAINED in the path
expect(readFileMock).toHaveBeenCalledWith(
expect.stringContaining('/Library/Logs/Podman Desktop/launchd-stdout.log'),
'utf8',
);
expect(readFileMock).toHaveBeenCalledWith(
expect.stringContaining('/Library/Logs/Podman Desktop/launchd-stderr.log'),
'utf8',
);
});

// Should return getWindowsSystemLogs if the platform is win32
// ~/AppData/Roaming/Podman Desktop/logs/podman-desktop.log
test('Should return getWindowsSystemLogs if the platform is win32', async () => {
// Mock exists to be true
vi.mock('node:fs');
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return true;
});

// Mock platform to be win32
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');

const readFileMock = vi.spyOn(fs.promises, 'readFile');
readFileMock.mockResolvedValue('content');

const zipFile = new Troubleshooting(undefined);
const getSystemLogsSpy = vi.spyOn(zipFile, 'getSystemLogs');

await zipFile.getSystemLogs();
expect(getSystemLogsSpy).toHaveBeenCalled();

// Expect it to have been called once as it checked podman-desktop.log
expect(readFileMock).toHaveBeenCalledTimes(1);

// Expect readFileMock to have been called with ~/AppData/Roaming/Podman Desktop/logs/podman-desktop.log but CONTAINED in the path
expect(readFileMock).toHaveBeenCalledWith(
expect.stringContaining('/AppData/Roaming/Podman Desktop/logs/podman-desktop.log'),
'utf8',
);
});

test('test generateLogFileName', async () => {
const fileName = generateLogFileName('test');

// Simple regex to check that the file name is in the correct format (YYYMMDDHHmmss)
expect(fileName).toMatch(/[0-9]{14}/);
expect(fileName).toContain('test');
});
118 changes: 118 additions & 0 deletions packages/main/src/plugin/troubleshooting.ts
@@ -0,0 +1,118 @@
/**********************************************************************
* Copyright (C) 2023 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 AdmZip from 'adm-zip';
import moment from 'moment';
import * as os from 'node:os';
import * as fs from 'node:fs';

const SYSTEM_FILENAME = 'system';

export interface FileMap {
fileName: string;
content: string;
}

export type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error';

export class Troubleshooting {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(private apiSender: any) {}

// The "main" function that is exposes that is used to gather
// all the logs and save them to a zip file.
// this also takes in the console logs and adds them to the zip file (see preload/src/index.ts) regarding memoryLogs
async saveLogs(console: { logType: LogType; message: string }[], destination: string): Promise<string[]> {
const systemLogs = await this.getSystemLogs();
const consoleLogs = this.getConsoleLogs(console);
const fileMaps = [...systemLogs, ...consoleLogs];
await this.saveLogsToZip(fileMaps, destination);
return fileMaps.map(fileMap => fileMap.fileName);
}

async saveLogsToZip(fileMaps: FileMap[], destination: string): Promise<void> {
if (fileMaps.length === 0) return;

const zip = new AdmZip();
fileMaps.forEach(fileMap => {
zip.addFile(fileMap.fileName, Buffer.from(fileMap.content, 'utf8'));
});
zip.writeZip(destination);
console.log(`Zip file saved to ${destination}`);
}

getConsoleLogs(consoleLogs: { logType: LogType; message: string }[]): FileMap[] {
const content = consoleLogs.map(log => `${log.logType} : ${log.message}`).join('\n');
return [{ fileName: generateLogFileName('console'), content }];
}

async getSystemLogs(): Promise<FileMap[]> {
switch (os.platform()) {
case 'darwin':
return this.getLogsFromFiles(
['launchd-stdout.log', 'launchd-stderr.log'],
`${os.homedir()}/Library/Logs/Podman Desktop`,
);
case 'win32':
return this.getLogsFromFiles(['podman-desktop'], `${os.homedir()}/AppData/Roaming/Podman Desktop/logs`);
default:
console.log('Unsupported platform for retrieving system logs');
return [];
}
}

private async getFileSystemContent(filePath: string, logName: string): Promise<FileMap> {
try {
const content = await fs.promises.readFile(filePath, 'utf8');
return { fileName: generateLogFileName(SYSTEM_FILENAME + '-' + logName), content };
} catch (error) {
console.error(`Error reading file from ${filePath}: `, error);
throw error;
}
}

private async getLogsFromFiles(logFiles: string[], logDir: string): Promise<FileMap[]> {
const logs: FileMap[] = [];
for (const file of logFiles) {
try {
const filePath = `${logDir}/${file}`;

// Check if the file exists, if not, skip it.
if (!fs.existsSync(filePath)) {
console.log(`File ${filePath} does not exist, skipping`);
continue;
}

const fileMap = await this.getFileSystemContent(filePath, file);
logs.push(fileMap);
} catch (error) {
console.error(`Error reading ${file}: `, error);
}
}
return logs;
}
}

export function generateLogFileName(filename: string, extension?: string): string {
// If the filename has an extension like .log, move it to the end ofthe generated name
// otherwise just use .txt
const filenameParts = filename.split('.');
// Use the file extension if it's provided, otherwise use the one from the file name, or default to txt
const fileExtension = extension ? extension : filenameParts.length > 1 ? filenameParts[1] : 'txt';
return `${filenameParts[0]}-${moment().format('YYYYMMDDHHmmss')}.${fileExtension}`;
}

0 comments on commit a88ff0b

Please sign in to comment.