From a88ff0b33a6f801dcc33ffca22ac9de7f148e6af Mon Sep 17 00:00:00 2001 From: Charlie Drage Date: Tue, 6 Feb 2024 13:54:32 -0500 Subject: [PATCH] feat: Add gather & download logs button in troubleshooting ### 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 ### What issues does this PR fix or reference? Closes https://github.com/containers/podman-desktop/issues/5048 ### How to test this PR? 1. Go to troubleshooting 2. Click the collect logs & download logs buttons. Signed-off-by: Charlie Drage --- packages/main/src/mainWindow.ts | 16 ++ packages/main/src/plugin/index.ts | 14 ++ .../main/src/plugin/troubleshooting.spec.ts | 204 ++++++++++++++++++ packages/main/src/plugin/troubleshooting.ts | 118 ++++++++++ packages/preload/src/index.ts | 28 +++ .../TroubleshootingGatherLogs.svelte | 56 +++++ .../TroubleshootingPage.svelte | 6 + 7 files changed, 442 insertions(+) create mode 100644 packages/main/src/plugin/troubleshooting.spec.ts create mode 100644 packages/main/src/plugin/troubleshooting.ts create mode 100644 packages/renderer/src/lib/troubleshooting/TroubleshootingGatherLogs.svelte diff --git a/packages/main/src/mainWindow.ts b/packages/main/src/mainWindow.ts index 6e851322e44f..41d6f8171071 100644 --- a/packages/main/src/mainWindow.ts +++ b/packages/main/src/mainWindow.ts @@ -131,6 +131,22 @@ async function createWindow(): Promise { }); }); + 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; diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 6a2801407339..de275cc499c5 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -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'; @@ -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); @@ -1279,6 +1282,17 @@ export class PluginSystem { return cliToolRegistry.getCliToolInfos(); }); + this.ipcHandle( + 'troubleshooting:saveLogs', + async ( + _listener, + consoleLogs: { logType: LogType; message: string }[], + destinaton: string, + ): Promise => { + return troubleshooting.saveLogs(consoleLogs, destinaton); + }, + ); + this.ipcHandle( 'cli-tool-registry:updateCliTool', async (_listener, id: string, loggerId: string): Promise => { diff --git a/packages/main/src/plugin/troubleshooting.spec.ts b/packages/main/src/plugin/troubleshooting.spec.ts new file mode 100644 index 000000000000..84d81bdb8526 --- /dev/null +++ b/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'); +}); diff --git a/packages/main/src/plugin/troubleshooting.ts b/packages/main/src/plugin/troubleshooting.ts new file mode 100644 index 000000000000..31b454e64b10 --- /dev/null +++ b/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 { + 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 { + 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 { + 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 { + 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 { + 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}`; +} diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 1609265cdc43..bfbaee595de1 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -1080,6 +1080,10 @@ function initExposure(): void { }, ); + contextBridge.exposeInMainWorld('troubleshootingSaveLogs', async (destinaton: string): Promise => { + return ipcInvoke('troubleshooting:saveLogs', memoryLogs, destinaton); + }); + contextBridge.exposeInMainWorld('getContributedMenus', async (context: string): Promise => { return ipcInvoke('menu-registry:getContributedMenus', context); }); @@ -1293,6 +1297,30 @@ function initExposure(): void { const dialogResponses = new Map(); + contextBridge.exposeInMainWorld('saveFileDialog', async (message: string, defaultPath: string) => { + // generate id + const dialogId = idDialog; + idDialog++; + + // create defer object + const defer = new Deferred(); + + // store the dialogID + dialogResponses.set(`${dialogId}`, (result: Electron.SaveDialogReturnValue) => { + defer.resolve(result); + }); + + // ask to open file dialog + ipcRenderer.send('dialog:saveFile', { + dialogId: `${dialogId}`, + message, + defaultPath, + }); + + // wait for response + return defer.promise; + }); + contextBridge.exposeInMainWorld( 'openFileDialog', async (message: string, filter?: { extensions: string[]; name: string }) => { diff --git a/packages/renderer/src/lib/troubleshooting/TroubleshootingGatherLogs.svelte b/packages/renderer/src/lib/troubleshooting/TroubleshootingGatherLogs.svelte new file mode 100644 index 000000000000..c08889910102 --- /dev/null +++ b/packages/renderer/src/lib/troubleshooting/TroubleshootingGatherLogs.svelte @@ -0,0 +1,56 @@ + + +
+
+ +
Gather Log Files
+
+
+
Bundle all available logs into a .zip
+
+ +
+ {#if logs.length > 0} +
+
    + {#each logs as log} +
  • +
    +
    + {log} generated +
    +
    +
  • + {/each} +
  • +
    +
    + {logs.length} logs collected and bundled as a .zip +
    +
    +
  • +
+
+ {/if} +
diff --git a/packages/renderer/src/lib/troubleshooting/TroubleshootingPage.svelte b/packages/renderer/src/lib/troubleshooting/TroubleshootingPage.svelte index 64079d207945..52e9e0d42132 100644 --- a/packages/renderer/src/lib/troubleshooting/TroubleshootingPage.svelte +++ b/packages/renderer/src/lib/troubleshooting/TroubleshootingPage.svelte @@ -2,6 +2,7 @@ import FormPage from '../ui/FormPage.svelte'; import Tab from '../ui/Tab.svelte'; import TroubleshootingDevToolsConsoleLogs from './TroubleshootingDevToolsConsoleLogs.svelte'; +import TroubleshootingGatherLogs from './TroubleshootingGatherLogs.svelte'; import TroubleshootingPageProviders from './TroubleshootingPageProviders.svelte'; import TroubleshootingPageStores from './TroubleshootingPageStores.svelte'; import Route from '/@/Route.svelte'; @@ -13,6 +14,7 @@ import Route from '/@/Route.svelte'; + @@ -24,6 +26,10 @@ import Route from '/@/Route.svelte'; + + + +