[MM-47202] - Desktop diagnostics (#2439)

* Add custom assertion to match object in array (#2358)

* Add custom assertion to match object in array

* Remove test that is not implemented yet

* [MM-48213] Add "Run diagnostics" menu item under Help (#2359)

* Add submenu item that runs diagnostics

* Add custom assertion to match object in array

* Remove test that is not implemented yet

* Add tests

* Add translation

* [MM-47206] Diagnostics steps setup (#2361)

* Add baseline code for diagnostics and their steps

* Fix failing test

* [MM-47206] [MM-48155] Obfuscate logs (#2369)

* Add logging hooks to mask sensitive data

* Add hook that truncates long strings in diagnostics logs

* Add template file for creating steps

* Add readme inside diagnostics

* [MM-48145] Diagnostics step 2 - internet connectivity (#2372)

* Add diagnostics step 2 - internet connectivity check

* Update tests

* [MM-48144] Diagnostics Step - Configure logger (#2390)

* Configure logger

* Move configure logger into step1

* Add file extension to fileName variable

* Diagnostics Step 2: Validate configuration (#2391)

* Resolve conflicts with base branch

* Update test and implement Code review suggestion

* Fix failing test

* [MM-48147]Diagnostics step 3 - server connectivity (#2397)

* Add step3: Check server connectivity by using the /api/v4/system/ping endpoint

* Fix failing tests

* Add better obfuscator functions that mask all types of data (#2399)

* Add better obfuscator functions that mask all types of data(string, array, objects)

* Update tests

* [MM-48148] Add Diagnostics step 4 - session validation (#2398)

* Add diagnostics step 4 - session data validation

* Fix failing tests

* [MM-48152] Add diagnostics step 5 - BrowserWindows checks (#2404)

* Add diagnostics step 5 - browserwindow checks for main window

* Add tests

* [MM-48151] Diagnostics step 6 - Permissions (#2409)

* Add diagnostics step 6 - Permissions check

* Check permissions for microphone ond screen onn mac, windows

* Update tests count in tests

* [MM-48551] Diagnostics step 7 - Performance & Memory (#2410)

* Add diagnostics step 6 - Permissions check

* Check permissions for microphone ond screen onn mac, windows

* Update tests count in tests

* Add diagnostics step 7 - performance and memory

* Fix failing tests

* [MM-48153] Add diagnostics step 8 - Log heuristics (#2418)

* Add diagnostics step 8 - Log heuristics

* Add diagnostics step 9 - config (#2422)

* [MM-48556] Diagnostics Step 10 - Crash reports (#2423)

* Add diagnostics step 9 - config

* Add diagnostics step 10 - include crash reports

* Update tests

* Add diagnostics step 11 - cookies report (#2427)

* [MM-48157] Diagnostics report (#2432)

* Add better logging and pretty print report

* Update last step

* Update log message

* Move log after hooks so that path is masked

* Use correct directory for diagnostics files
This commit is contained in:
Tasos Boulis 2022-12-02 16:33:42 +02:00 committed by GitHub
parent 567b48516b
commit 161ae99e94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1809 additions and 30 deletions

View file

@ -24,7 +24,13 @@
"ICONNAME",
"inputflash",
"loadscreen",
"mailhost",
"mailserver",
"MMAUTHTOKEN",
"MMCSRF",
"mmjstool",
"MMUSERID",
"mochawesome",
"NOSERVERS",
"Ochiai",
"officedocument",

View file

@ -67,6 +67,7 @@
"main.menus.app.help.downloadUpdate": "Download Update",
"main.menus.app.help.learnMore": "Learn More...",
"main.menus.app.help.restartAndUpdate": "Restart and Update",
"main.menus.app.help.RunDiagnostics": "Run diagnostics",
"main.menus.app.help.versionString": "Version {version}{commit}",
"main.menus.app.history": "&History",
"main.menus.app.history.back": "Back",

View file

@ -109,7 +109,10 @@
"__SKIP_ONBOARDING_SCREENS__": false
},
"setupFiles": [
"./src/jestSetup.js"
"./src/jest/jestSetup.js"
],
"setupFilesAfterEnv": [
"./src/jest/jestSetupAfterEnv.js"
],
"reporters": [
"default",

View file

@ -24,6 +24,7 @@ jest.mock('main/Validator', () => ({
validateV1ConfigData: (configData) => (configData.version === 1 ? configData : null),
validateV2ConfigData: (configData) => (configData.version === 2 ? configData : null),
validateV3ConfigData: (configData) => (configData.version === 3 ? configData : null),
validateConfigData: (configData) => (configData.version === 3 ? configData : null),
}));
jest.mock('common/tabs/TabView', () => ({

View file

@ -357,19 +357,8 @@ export class Config extends EventEmitter {
configData = this.readFileSync(this.configFilePath);
// validate based on config file version
switch (configData.version) {
case 3:
configData = Validator.validateV3ConfigData(configData)!;
break;
case 2:
configData = Validator.validateV2ConfigData(configData)!;
break;
case 1:
configData = Validator.validateV1ConfigData(configData)!;
break;
default:
configData = Validator.validateV0ConfigData(configData)!;
}
configData = Validator.validateConfigData(configData);
if (!configData) {
throw new Error('Provided configuration file does not validate, using defaults instead.');
}

View file

@ -0,0 +1,49 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {REGEX_EMAIL} from './constants';
const VALID_EMAILS_LIST = [
['email@example.com'],
['firstname.lastname@example.com'],
['disposable.style.email.with+symbol@example.com'],
['other.email-with-hyphen@example.com'],
['fully-qualified-domain@example.com'],
['user.name+tag+sorting@example.com'],
['x@example.com'],
['example-indeed@strange-example.com'],
['test/test@test.com'],
['admin@mailserver1'],
['example@s.example'],
['john..doe@example.org'],
['mailhost!username@example.org'],
['user%example.com@example.org'],
['user-@example.org'],
['email@subdomain.example.com'],
['firstname+lastname@example.com'],
['email@123.123.123.123'],
['1234567890@example.com'],
['email@example-one.com'],
['_______@example.com'],
['email@example.name'],
['email@example.museum'],
['email@example.co.jp'],
['firstname-lastname@example.com'],
];
const INVALID_EMAILS_LIST = [
['Abc.example.com'],
['QA[icon]CHOCOLATE[icon]@test.com'],
];
describe('main/common/constants', () => {
describe('Email regular expression', () => {
it.each(VALID_EMAILS_LIST)('%#:Should be VALID email address: %s', (a) => {
expect(REGEX_EMAIL.test(a)).toBe(true);
});
it.each(INVALID_EMAILS_LIST)('%#: Should be INVALID email address: %s', (a) => {
expect(REGEX_EMAIL.test(a)).toBe(false);
});
});
});

View file

@ -20,3 +20,29 @@ export const UPDATE_DOWNLOAD_ITEM: Omit<DownloadedItem, 'filename' | 'state'> =
receivedBytes: 0,
totalBytes: 0,
};
// Regular expressions
export const REGEX_EMAIL = /[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*/; // based on W3C input type email regex
export const REGEX_IPV4 = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
export const REGEX_URL = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
export const REGEX_PATH_WIN32 = /(?:[a-z]:)?[/\\](?:[./\\ ](?![./\\\n])|[^<>:"|?*./\\ \n])+[a-zA-Z0-9]./;
export const REGEX_PATH_DARWIN = /([/]{1}[a-z0-9.]+)+(\/?)|^([/])/;
export const REGEX_PATH_LINUX = /([/]{1}[a-z0-9.]+)+(\/?)|^([/])/; // same as darwin
export const REGEX_LOG_FILE_LINE = /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}.\d{3})\]\s\[(silly|debug|verbose|info|warn|error)\]\s+(.*)/;
// Masks
export const MASK_EMAIL = 'EMAIL';
export const MASK_IPV4 = 'IPV4';
export const MASK_PATH = 'PATH';
export const MASK_URL = 'URL';
export const LOGS_MAX_STRING_LENGTH = 63;
// We use this URL inside the Diagnostics to check if the computer has internet connectivity
export const IS_ONLINE_ENDPOINT = 'https://community.mattermost.com/api/v4/system/ping';
export const COOKIE_NAME_USER_ID = 'MMUSERID';
export const COOKIE_NAME_CSRF = 'MMCSRF';
export const COOKIE_NAME_AUTH_TOKEN = 'MMAUTHTOKEN';

View file

@ -12,16 +12,25 @@ jest.mock('main/constants', () => ({
updatePaths: jest.fn(),
}));
jest.mock('electron-log', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
silly: jest.fn(),
transports: {
file: {
level: '',
jest.mock('electron-log', () => {
const logLevelsFn = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
silly: jest.fn(),
};
return {
create: jest.fn(() => ({
...logLevelsFn,
})),
...logLevelsFn,
transports: {
file: {
level: '',
},
},
},
}));
};
});

View file

@ -0,0 +1,25 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
function toContainObject(received, argument) {
const pass = this.equals(received,
expect.arrayContaining([
expect.objectContaining(argument),
]),
);
if (pass) {
return {
message: () => (`expected ${this.utils.printReceived(received)} not to contain object ${this.utils.printExpected(argument)}`),
pass: true,
};
}
return {
message: () => (`expected ${this.utils.printReceived(received)} to contain object ${this.utils.printExpected(argument)}`),
pass: false,
};
}
expect.extend({
toContainObject,
});

View file

@ -5,7 +5,7 @@ import log from 'electron-log';
import Joi from 'joi';
import {Args} from 'types/args';
import {ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config';
import {AnyConfig, ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config';
import {DownloadedItems} from 'types/downloads';
import {SavedWindowState} from 'types/mainWindow';
import {AppState} from 'types/appState';
@ -260,6 +260,19 @@ export function validateV3ConfigData(data: ConfigV3) {
return validateAgainstSchema(data, configDataSchemaV3);
}
export function validateConfigData(data: AnyConfig) {
switch (data.version) {
case 3:
return validateV3ConfigData(data)!;
case 2:
return validateV2ConfigData(data)!;
case 1:
return validateV1ConfigData(data)!;
default:
return validateV0ConfigData(data)!;
}
}
// validate certificate.json
export function validateCertificateStore(data: string | Record<string, ComparableCertificate>) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));

View file

@ -0,0 +1,25 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DiagnosticsStepConstructorPayload} from 'types/diagnostics';
import {addDurationToFnReturnObject} from './steps/internal/utils';
const genericStepName = 'diagnostic-step/generic';
class DiagnosticsStep {
name: DiagnosticsStepConstructorPayload['name'];
retries: DiagnosticsStepConstructorPayload['retries'];
run: DiagnosticsStepConstructorPayload['run'];
constructor({name = genericStepName, retries = 0, run}: DiagnosticsStepConstructorPayload) {
if (typeof run !== 'function') {
throw new Error(`"run" function is missing from step ${name}`);
}
this.name = name;
this.retries = retries;
this.run = addDurationToFnReturnObject(run);
}
}
export default DiagnosticsStep;

View file

@ -0,0 +1,22 @@
# Desktop Diagnostics
This directory contains the code for running the diagnostics of the desktop application. (entrypoint `index.ts`)
This readme file's purpose is to explain the code, the structure and how to contribute to it.
## How it works?
The class `DiagnosticsModule` in `index.ts` is the "orchestrator" that runs specific steps/tasks one at a time. It keeps track of whether or not a specific
step has succeeded or not and stores the return values of the steps.
## Diagnostics Steps
The diagnostic steps at this moment are:
| Step | Name | Description |
| :---: | :---: | :--- |
| 0 | logger | Validates that the diagnostics write to the correct file and the log level is "debug" |
## Future enhancements
- Run steps in parallel (if necessary)
- Background diagnostics monitoring

View file

@ -0,0 +1,25 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Diagnostics from '.';
jest.mock('main/windows/windowManager', () => ({
mainWindow: {},
}));
jest.mock('common/config', () => ({
configFilePath: 'mock/config/filepath/',
}));
describe('main/diagnostics/index', () => {
it('should be initialized with correct values', () => {
const d = Diagnostics;
expect(d.stepTotal).toBe(0);
expect(d.stepCurrent).toBe(0);
expect(d.report).toEqual([]);
});
it('should count the steps correctly', () => {
const d = Diagnostics;
expect(d.getStepCount()).toBe(12);
});
});

View file

@ -0,0 +1,186 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shell} from 'electron';
import log, {ElectronLog} from 'electron-log';
import {DiagnosticsReport} from 'types/diagnostics';
import DiagnosticsStep from './DiagnosticStep';
import Step0 from './steps/step0.logLevel';
import Step1 from './steps/step1.internetConnection';
import Step10 from './steps/step10.crashReports';
import Step11 from './steps/step11.auth';
import Step2 from './steps/step2.configValidation';
import Step3 from './steps/step3.serverConnectivity';
import Step4 from './steps/step4.sessionDataValidation';
import Step5 from './steps/step5.browserWindows';
import Step6 from './steps/step6.permissions';
import Step7 from './steps/step7.performance';
import Step8 from './steps/step8.logHeuristics';
import Step9 from './steps/step9.config';
const SORTED_STEPS: DiagnosticsStep[] = [
Step0,
Step1,
Step2,
Step3,
Step4,
Step5,
Step6,
Step7,
Step8,
Step9,
Step10,
Step11,
];
const maxStepNameLength = Math.max(...SORTED_STEPS.map((s) => s.name.length));
const HASHTAGS = '#'.repeat(20);
class DiagnosticsModule {
stepCurrent = 0;
stepTotal = 0;
report: DiagnosticsReport = [];
logger: ElectronLog = log.create('diagnostics-logger');
run = async () => {
try {
if (this.isRunning()) {
this.logger.warn('Diagnostics is already running');
return;
}
this.logger.debug('Diagnostics.run');
this.initializeValues();
this.sendNotificationDiagnosticsStarted();
await this.executeSteps();
this.printReport();
this.showLogFile();
this.initializeValues(true);
} catch (error) {
this.logger.error('Diagnostics.run Error: ', {error});
this.initializeValues(true);
}
}
initializeValues = (clear = false) => {
this.logger.transports.file.level = 'silly';
this.logger.transports.console.level = 'silly';
this.logger.debug('Diagnostics.initializeValues');
this.stepCurrent = 0;
this.stepTotal = clear ? 0 : this.getStepCount();
this.report = [];
}
getStepCount = () => {
const stepsCount = SORTED_STEPS.length;
this.logger.debug('Diagnostics.getStepCount', {stepsCount});
return stepsCount;
}
executeSteps = async () => {
this.logger.info('Diagnostics.executeSteps Started');
let index = 0;
for (const step of SORTED_STEPS) {
const reportStep = {
name: step.name,
step: index,
};
if (this.isValidStep(step)) {
// eslint-disable-next-line no-await-in-loop
const stepResult = await step.run(this.logger);
this.printStepStart(step.name);
this.addToReport({
...stepResult,
...reportStep,
payload: stepResult.payload,
});
this.logger.info({stepResult});
this.printStepEnd(step.name);
} else {
this.addToReport({
...reportStep,
succeeded: false,
duration: 0,
});
this.logger.warn('Diagnostics.executeSteps UnknownStep', {index, step});
}
index++;
this.stepCurrent = index;
}
this.logger.info('Diagnostics.executeSteps Finished');
}
printReport = () => {
const totalStepsCount = this.getStepCount();
const successfulStepsCount = this.getSuccessfulStepsCount();
this.printStepStart('Report');
this.logger.info({report: this.report});
this.logger.info(`| index | name${this.fillSpaces(maxStepNameLength - 4)} | succeeded |`);
this.logger.info(`| ---${this.fillSpaces(3)}| ---${this.fillSpaces(maxStepNameLength - 3)} | ---${this.fillSpaces(7)}|`);
this.report.forEach((step, index) => {
this.logger.info(`| ${index}${this.fillSpaces(6 - index.toString().length)}| ${step.name}${this.fillSpaces(maxStepNameLength - step.name.length)} | ${step.succeeded}${this.fillSpaces(step.succeeded ? 6 : 5)}|`);
});
this.logger.info(`| ---${this.fillSpaces(3)}| ---${this.fillSpaces(maxStepNameLength - 3)} | ---${this.fillSpaces(7)}|`);
this.logger.info(`${successfulStepsCount} out of ${totalStepsCount} steps succeeded`);
this.printStepEnd('Report');
}
showLogFile = () => {
const pathToFile = this.getLoggerFilePath();
this.logger.debug('Diagnostics.showLogFile', {pathToFile});
shell.showItemInFolder(pathToFile);
}
sendNotificationDiagnosticsStarted = () => {
this.logger.debug('Diagnostics sendNotification DiagnosticsStarted');
}
isValidStep = (step: unknown) => {
return step instanceof DiagnosticsStep;
}
getLoggerFilePath = () => {
return this.logger.transports.file.getFile()?.path;
}
isRunning = () => {
return this.stepTotal > 0 && this.stepCurrent >= 0;
}
getSuccessfulStepsCount = () => {
return this.report.filter((step) => step.succeeded).length;
}
private printStepStart = (name: string) => {
this.logger.info(`${HASHTAGS} ${name} START ${HASHTAGS}`);
}
private printStepEnd = (name: string) => {
this.logger.info(`${HASHTAGS} ${name} END ${HASHTAGS}`);
}
private addToReport(data: DiagnosticsReport[number]): void {
this.report = [
...this.report,
data,
];
}
private fillSpaces = (i: number) => {
if (typeof i !== 'number' || i <= 0) {
return '';
}
return ' '.repeat(i);
}
}
const Diagnostics = new DiagnosticsModule();
export default Diagnostics;

View file

@ -0,0 +1,107 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MASK_EMAIL, MASK_PATH} from 'common/constants';
import {maskMessageDataHook} from './loggerHooks';
const loggerMock = {
transports: {
file: 'file',
},
};
function findOccurrencesInString(search, text) {
const regex = new RegExp(search, 'g');
return (text.match(regex))?.length || 0;
}
describe('main/diagnostics/loggerHooks', () => {
it('should mask nothing when it prints only to console', () => {
const message = {
data: ['password email@test.com https://192.168.1.1'],
};
const result = maskMessageDataHook(loggerMock)(message, 'console').data[0];
expect(result).toBe(message.data[0]);
});
it('should return empty "" if the message includes the string "password"', () => {
const message = {
data: ['Password: someRandomPassword'],
};
const result = maskMessageDataHook(loggerMock)(message, 'file');
expect(result.data[0]).toBe('');
});
it('should mask emails', () => {
const message = {
data: ['email@test.com email2@test.com'],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(findOccurrencesInString(MASK_EMAIL, result)).toBe(2);
});
it('should mask IPV4 addresses', () => {
const IPs = ['192.168.20.44', '1.1.1.1', '255.255.255.255'];
const message = {
data: [`:${IPs[0]} https://${IPs[1]} networkPc://${IPs[2]}`],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(IPs.some((ip) => result.includes(ip))).toBe(false);
});
it('should mask URLs', () => {
const URLs = ['http://www.google.com', 'https://community.mattermost.com', 'https://someWebsite.com'];
const message = {
data: [`${URLs[0]} https://${URLs[1]} http://${URLs[2]}`],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(URLs.some((url) => result.includes(url))).toBe(false);
});
describe('should mask paths for all OSs', () => {
it('darwin', () => {
const message = {
data: ['/Users/user/Projects/desktop /Users/user/Projects/desktop/file.txt /Users/user/Projects/desktop/folder withSpace/file.txt'],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(findOccurrencesInString(MASK_PATH, result)).toBe(4);
});
it('linux', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
const message = {
data: ['/Users/user/Projects/desktop /Users/user/Projects/desktop/file.txt /Users/user/Projects/desktop/folder withSpace/file.txt'],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(findOccurrencesInString(MASK_PATH, result)).toBe(4);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
});
it('windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'win32',
});
const message = {
data: ['C:/Users/user/Desktop/download.pdf C:/Users/user/Desktop/folder withSpace/file.txt'],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(findOccurrencesInString(MASK_PATH, result)).toBe(3);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
});
});
it('should truncate very longs substrings', () => {
const message = {
data: ['ThisIsAVeryVeryVeryVeryVeryVeryVeryVeryLongStringProbablyAToken'],
};
const result = maskMessageDataHook(loggerMock)(message, 'file').data[0];
expect(result).toBe('This...en');
});
});

View file

@ -0,0 +1,28 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog} from 'electron-log';
import {obfuscateByType} from './obfuscators';
type ElectronLogHook = ElectronLog['hooks'][number];
type ElectronLogHookCreator = (l: ElectronLog) => ElectronLogHook;
export const maskMessageDataHook: ElectronLogHookCreator = (logger) => (message, transport) => {
if (transport !== logger.transports.file) {
return message;
}
// iterate the arguments of the log command, eg log.debug(a, b, c, ...);
for (let i = 0; i < message.data.length; i++) {
message.data[i] = obfuscateByType(message.data[i]);
}
return message;
};
const loggerHooks: (logger: ElectronLog) => ElectronLog['hooks'] = (logger) => [
maskMessageDataHook(logger),
];
export default loggerHooks;

View file

@ -0,0 +1,83 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MASK_EMAIL, MASK_IPV4, MASK_PATH, MASK_URL, REGEX_EMAIL, REGEX_IPV4, REGEX_PATH_DARWIN, REGEX_PATH_LINUX, REGEX_PATH_WIN32, REGEX_URL} from 'common/constants';
import {truncateString} from './utils';
const isDarwin = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const isWin = process.platform === 'win32';
function maskDataInString(str: string): string {
let maskedStr = str;
if (!str || typeof str !== 'string') {
return str;
}
// Specific keywords
if (str?.toLowerCase?.().includes('password')) {
return '';
}
// Emails
if (REGEX_EMAIL.test(str)) {
maskedStr = maskedStr.replaceAll(RegExp(REGEX_EMAIL, 'gi'), MASK_EMAIL);
}
// IP addresses
if (REGEX_IPV4.test(str)) {
maskedStr = maskedStr.replaceAll(RegExp(REGEX_IPV4, 'gi'), MASK_IPV4);
}
// URLs
if (REGEX_URL.test(str)) {
maskedStr = maskedStr.replaceAll(RegExp(REGEX_URL, 'gi'), MASK_URL);
}
// Paths
if (isDarwin) {
if (REGEX_PATH_DARWIN.test(str)) {
maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_DARWIN, 'gi'), MASK_PATH);
}
} else if (isLinux) {
if (REGEX_PATH_LINUX.test(str)) {
maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_LINUX, 'gi'), MASK_PATH);
}
} else if (isWin) {
if (REGEX_PATH_WIN32.test(str)) {
maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_WIN32, 'gi'), MASK_PATH);
}
}
// Very long strings will be truncated (eg tokens)
maskedStr = maskedStr.split(/,| |\r?\n/)?.map?.((str: string) => truncateString(str))?.join?.(' ');
return maskedStr;
}
function maskDataInArray(arr: unknown[]): unknown[] {
return arr.map((el) => {
return obfuscateByType(el);
});
}
function maskDataInObject(obj: Record<string, unknown>): Record<string, unknown> {
return Object.keys(obj).reduce<Record<string, unknown>>((acc, key) => {
acc[key] = obfuscateByType(obj[key]);
return acc;
}, {});
}
export function obfuscateByType(item: unknown): unknown {
const elType = typeof item;
if (elType === 'string') {
return maskDataInString(item as string);
} else if (elType === 'object') {
if (Array.isArray(item)) {
return maskDataInArray(item);
}
return maskDataInObject(item as Record<string, unknown>);
}
return item;
}

View file

@ -0,0 +1,186 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import config from 'common/config';
import {addDurationToFnReturnObject, boundsOk, browserWindowVisibilityStatus, checkPathPermissions, truncateString, webContentsCheck} from './utils';
const sleep = (ms) => new Promise((resolve) => {
setTimeout(() => resolve(), ms);
});
const timeToSleep = 100;
jest.mock('fs', () => ({
promises: {
access: jest.fn(),
},
constants: {
W_OK: 0,
},
}));
jest.mock('common/config', () => ({
downloadLocation: 'path/to/file.txt',
}));
describe('main/diagnostics/utils', () => {
describe('addDurationToFnReturnObject', () => {
it('should measure the execution time of a function and include it in the response', async () => {
const functionToMeasure = async () => {
await sleep(timeToSleep);
};
const fn = addDurationToFnReturnObject(functionToMeasure);
const b = await fn();
expect(b.duration).toBeGreaterThan(timeToSleep - 10);
expect(b.duration).toBeLessThan(timeToSleep * 1.5);
});
});
describe('truncateString', () => {
it('should truncate very long string', () => {
const str = 'ThisIsAVeryVeryVeryVeryVeryVeryVeryVeryLongStringProbablyAToken';
expect(truncateString(str)).toBe('This...en');
});
});
describe('boundsOk', () => {
const bounds = {
x: 17,
y: 42,
width: 800,
height: 600,
};
const zeroBounds = {
x: 0,
y: 0,
width: 0,
height: 0,
};
/** TRUE */
it('should return true if the bounds Rectangle is valid - not strict', () => {
expect(boundsOk(bounds)).toBe(true);
});
it('should return true if the bounds Rectangle is valid - strict', () => {
expect(boundsOk(bounds, true)).toBe(true);
});
it('should return true if the bounds Rectangle is valid - not strict', () => {
expect(boundsOk(zeroBounds)).toBe(true);
});
/** FALSE */
it('should return false if the bounds Rectangle is invalid - strict', () => {
expect(boundsOk(zeroBounds, true)).toBe(false);
});
it('should return false if the bounds Rectangle is invalid - not strict', () => {
expect(boundsOk({x: 0, y: 0})).toBe(false);
});
it('should return false if the bounds Rectangle is invalid - not strict', () => {
expect(boundsOk({x: 0, y: 0, width: 0})).toBe(false);
});
it('should return false if the bounds Rectangle is invalid - not strict', () => {
expect(boundsOk('a_string')).toBe(false);
});
it('should return false if the bounds Rectangle is invalid - not strict', () => {
expect(boundsOk(42)).toBe(false);
});
it('should return false if the bounds Rectangle is invalid - not strict', () => {
expect(boundsOk({...bounds, x: '10'})).toBe(false);
});
});
describe('browserWindowVisibilityStatus', () => {
const bWindow = {
getBounds: () => ({x: 0, y: 0, width: 800, height: 600}),
getOpacity: () => 1,
isDestroyed: () => false,
isVisible: () => true,
isEnabled: () => true,
getBrowserViews: () => [{
getBounds: () => ({
x: 0,
y: 0,
width: 800,
height: 500,
}),
}],
};
it('should return true if window ok', () => {
expect(browserWindowVisibilityStatus('testWindow', bWindow).every((check) => check.ok)).toBe(true);
});
it('should return false if bounds not ok', () => {
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, getBounds: () => ({x: -1, y: -1, width: 200, height: 100})}).every((check) => check.ok)).toBe(false);
});
it('should return false if opacity is 0', () => {
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, getOpacity: () => 0.0}).every((check) => check.ok)).toBe(false);
});
it('should return false if window is destroyed', () => {
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isDestroyed: () => true}).every((check) => check.ok)).toBe(false);
});
it('should return false if window is not visible', () => {
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isVisible: () => false}).every((check) => check.ok)).toBe(false);
});
it('should return false if window is not enabled', () => {
expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isEnabled: () => false}).every((check) => check.ok)).toBe(false);
});
it('should return false if a child browserView has invalid bounds', () => {
expect(browserWindowVisibilityStatus('testWindow', {
...bWindow,
getBrowserViews: () => [{
getBounds: () => ({
x: -1,
y: -4000,
width: 800,
height: 500,
}),
}],
}).every((check) => check.ok)).toBe(false);
});
});
describe('webContentsCheck', () => {
it('should return true if webContents are ok', () => {
expect(webContentsCheck({
isCrashed: () => false,
isDestroyed: () => false,
isWaitingForResponse: () => false,
})).toBe(true);
});
it('should return false if webcontents is undefined', () => {
expect(webContentsCheck()).toBe(false);
});
it('should return false if webContents has crashed', () => {
expect(webContentsCheck({
isCrashed: () => true,
isDestroyed: () => false,
isWaitingForResponse: () => false,
})).toBe(false);
});
it('should return false if webContents is destroyed', () => {
expect(webContentsCheck({
isCrashed: () => false,
isDestroyed: () => true,
isWaitingForResponse: () => false,
})).toBe(false);
});
it('should return false if webContents is waiting for response', () => {
expect(webContentsCheck({
isCrashed: () => false,
isDestroyed: () => false,
isWaitingForResponse: () => true,
})).toBe(false);
});
});
describe('checkPathPermissions', () => {
it('should return {ok: true}', async () => {
fs.promises.access.mockImplementation(() => Promise.resolve(undefined));
expect(await checkPathPermissions(config.downloadLocation, fs.constants.W_OK)).toStrictEqual({ok: true});
});
it('should return {ok: false}', async () => {
const error = new Error('some error');
fs.promises.access.mockImplementation(() => Promise.reject(error));
expect(await checkPathPermissions(config.downloadLocation, fs.constants.W_OK)).toStrictEqual({ok: false, error});
});
});
});

View file

@ -0,0 +1,242 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import https from 'https';
import readline from 'readline';
import {BrowserWindow, Rectangle, WebContents} from 'electron';
import log, {ElectronLog, LogLevel} from 'electron-log';
import {AddDurationToFnReturnObject, LogFileLineData, LogLevelAmounts, WindowStatus} from 'types/diagnostics';
import {IS_ONLINE_ENDPOINT, LOGS_MAX_STRING_LENGTH, REGEX_LOG_FILE_LINE} from 'common/constants';
export function dateTimeInFilename(date?: Date) {
const now = date ?? new Date();
return `${now.getDate()}-${now.getMonth()}-${now.getFullYear()}_${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}-${now.getMilliseconds()}`;
}
export function boundsOk(bounds?: Rectangle, strict = false): boolean {
if (!bounds) {
return false;
}
if (typeof bounds !== 'object') {
return false;
}
const propertiesOk = ['x', 'y', 'width', 'height'].every((key) => Object.prototype.hasOwnProperty.call(bounds, key));
const valueTypesOk = Object.values(bounds).every((value) => typeof value === 'number');
if (!propertiesOk || !valueTypesOk) {
return false;
}
if (strict) {
return bounds.height > 0 && bounds.width > 0 && bounds.x >= 0 && bounds.y >= 0;
}
return bounds.height >= 0 && bounds.width >= 0 && bounds.x >= 0 && bounds.y >= 0;
}
export const addDurationToFnReturnObject: AddDurationToFnReturnObject = (run) => {
return async (logger) => {
const startTime = Date.now();
const runReturnValues = await run(logger);
return {
...runReturnValues,
duration: Date.now() - startTime,
};
};
};
export function truncateString(str: string, maxLength = LOGS_MAX_STRING_LENGTH): string {
if (typeof str === 'string') {
const length = str.length;
if (length >= maxLength) {
return `${str.substring(0, 4)}...${str.substring(length - 2, length)}`;
}
}
return str;
}
export async function isOnline(logger: ElectronLog = log, url = IS_ONLINE_ENDPOINT): Promise<boolean> {
return new Promise<boolean>((resolve) => {
https.get(url, (resp) => {
let data = '';
// A chunk of data has been received.
resp.on('data', (chunk) => {
data += chunk;
});
// The whole response has been received. Print out the result.
resp.on('end', () => {
logger.debug('resp.on.end', {data});
const respBody = JSON.parse(data);
if (respBody.status === 'OK') {
resolve(true);
return;
}
resolve(false);
});
}).on('error', (err) => {
logger.error('diagnostics isOnline Error', {err});
resolve(false);
});
});
}
export function browserWindowVisibilityStatus(name: string, bWindow?: BrowserWindow): WindowStatus {
const status: WindowStatus = [];
if (!bWindow) {
status.push({
name: 'windowExists',
ok: false,
});
return status;
}
const bounds = bWindow.getBounds();
const opacity = bWindow.getOpacity();
const destroyed = bWindow.isDestroyed();
const visible = bWindow.isVisible();
const enabled = bWindow.isEnabled();
const browserViewsBounds = bWindow.getBrowserViews()?.map((view) => view.getBounds());
status.push({
name: 'windowExists',
ok: true,
});
status.push({
name: 'bounds',
ok: boundsOk(bounds, true),
data: bounds,
});
status.push({
name: 'opacity',
ok: opacity > 0 && opacity <= 1,
data: opacity,
});
status.push({
name: 'destroyed',
ok: !destroyed,
});
status.push({
name: 'visible',
ok: visible,
});
status.push({
name: 'enabled',
ok: enabled,
});
status.push({
name: 'browserViewsBounds',
ok: browserViewsBounds.every((bounds) => boundsOk(bounds)),
data: browserViewsBounds,
});
return status;
}
export function webContentsCheck(webContents?: WebContents) {
if (!webContents) {
return false;
}
return !webContents.isCrashed() && !webContents.isDestroyed() && !webContents.isWaitingForResponse();
}
export async function checkPathPermissions(path?: fs.PathLike, mode?: number) {
try {
if (!path) {
throw new Error('Invalid path');
}
await fs.promises.access(path, mode);
return {
ok: true,
};
} catch (error) {
return {
ok: false,
error,
};
}
}
function parseLogFileLine(line: string, lineMatchPattern: RegExp): LogFileLineData {
const data = line.match(lineMatchPattern);
return {
text: line,
date: data?.[1],
logLevel: data?.[2] as LogLevel,
};
}
/**
* The current setup of `electron-log` rotates the file when it reaches ~1mb. It's safe to assume that the file will not be large enough to cause
* issues reading it in the same process. If this read function ever causes performance issues we should either execute it in a child process or
* read up to X amount of lines (eg 10.000)
*/
export async function readFileLineByLine(path: fs.PathLike, lineMatchPattern = REGEX_LOG_FILE_LINE): Promise<{lines: LogFileLineData[]; logLevelAmounts: LogLevelAmounts}> {
const logLevelAmounts = {
silly: 0,
debug: 0,
verbose: 0,
info: 0,
warn: 0,
error: 0,
};
const lines: LogFileLineData[] = [];
if (!path) {
return {
lines,
logLevelAmounts,
};
}
const fileStream = fs.createReadStream(path);
const rl = readline.createInterface({
input: fileStream,
/**
* Note: we use the crlfDelay option to recognize all instances of CR LF
* ('\r\n') in input.txt as a single line break.
*/
crlfDelay: Infinity,
});
let i = -1;
for await (const line of rl) {
const isValidLine = new RegExp(lineMatchPattern, 'gi').test(line);
if (isValidLine || i === -1) {
i++;
const lineData = parseLogFileLine(line, lineMatchPattern);
if (lineData.logLevel) {
logLevelAmounts[lineData.logLevel]++;
}
//push in array as new line
lines.push(lineData);
} else {
//concat with previous line
lines[i].text = `${lines[i].text}${line}`;
}
// exit loop in edge case of very large file or infinite loop
if (i >= 100000) {
break;
}
}
return {
lines,
logLevelAmounts,
};
}

View file

@ -0,0 +1,37 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
const stepName = 'Step-X';
const stepDescriptiveName = 'Template';
// COPY & PASTE this file to create a new step
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
await Promise.resolve();
return {
message: `${stepName} finished successfully`,
succeeded: true,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const StepTemplate = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default StepTemplate;

View file

@ -0,0 +1,51 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'path';
import {app} from 'electron';
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
import loggerHooks from './internal/loggerHooks';
import {dateTimeInFilename} from './internal/utils';
const stepName = 'Step-0';
const stepDescriptiveName = 'logConfig';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const filename = `diagnostics_${dateTimeInFilename()}.txt`;
const pathToFile = path.join(app.getPath('userData'), `diagnostics/${filename}`);
logger.transports.file.resolvePath = () => pathToFile;
logger.transports.file.fileName = filename;
logger.hooks.push(...loggerHooks(logger));
logger.transports.file.level = 'silly';
logger.transports.console.level = 'silly';
logger.debug('ConfigureLogger', {filename, pathToFile});
return {
message: `${stepName} finished successfully`,
succeeded: true,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step0 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step0;

View file

@ -0,0 +1,43 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
import {isOnline} from './internal/utils';
const stepName = 'Step-1';
const stepDescriptiveName = 'internetConnection';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const success = await isOnline(logger);
if (success) {
return {
message: `${stepName} finished successfully`,
succeeded: true,
};
}
return {
message: `${stepName} failed`,
succeeded: false,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step1 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step1;

View file

@ -0,0 +1,54 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import path from 'path';
import {app} from 'electron';
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
const stepName = 'Step-10';
const stepDescriptiveName = 'CrashReports';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const pathOfCrashReports = app.getPath('userData');
const allDirFiles = await fs.promises.readdir(pathOfCrashReports);
const crashReportFiles = allDirFiles.filter((fileName) => fileName.startsWith('uncaughtException-'));
const crashReportData = await Promise.all(crashReportFiles.map(async (fileName) => {
return {
data: await fs.promises.readFile(path.join(pathOfCrashReports, fileName), {encoding: 'utf-8'}),
fileName,
};
}));
const payload = {
pathOfCrashReports,
crashReportData,
};
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step10 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step10;

View file

@ -0,0 +1,50 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {session} from 'electron';
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
const stepName = 'Step-11';
const stepDescriptiveName = 'AuthSSO';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const cookies = await session.defaultSession.cookies.get({});
if (!cookies) {
throw new Error('No cookies found');
}
const payload = cookies.map((cookie) => {
return {
name: cookie?.name,
expirationDate: cookie?.expirationDate,
session: cookie?.session,
};
});
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step11 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step11;

View file

@ -0,0 +1,48 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import Config from 'common/config';
import * as Validator from 'main/Validator';
import DiagnosticsStep from '../DiagnosticStep';
const stepName = 'Step-2';
const stepDescriptiveName = 'configValidation';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const configData = JSON.parse(fs.readFileSync(Config.configFilePath, 'utf8'));
// validate based on config file version
const validData = Validator.validateConfigData(configData);
if (!validData) {
throw new Error(`Config validation failed. Config: ${JSON.stringify(Config.combinedData, null, 4)}`);
}
return {
message: `${stepName} finished successfully`,
succeeded: true,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step2 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step2;

View file

@ -0,0 +1,56 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import Config from 'common/config';
import DiagnosticsStep from '../DiagnosticStep';
import {isOnline} from './internal/utils';
const stepName = 'Step-3';
const stepDescriptiveName = 'serverConnectivity';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const teams = Config.combinedData?.teams || [];
await Promise.all(teams.map(async (team) => {
logger.debug('Pinging server: ', team.url);
if (!team.name || !team.url) {
throw new Error(`Invalid server configuration. Team Url: ${team.url}, team name: ${team.name}`);
}
const serverOnline = await isOnline(logger, `${team.url}/api/v4/system/ping`);
if (!serverOnline) {
throw new Error(`Server appears to be offline. Team url: ${team.url}`);
}
}));
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload: teams,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step3 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step3;

View file

@ -0,0 +1,54 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {session} from 'electron';
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import {COOKIE_NAME_AUTH_TOKEN, COOKIE_NAME_CSRF, COOKIE_NAME_USER_ID} from 'common/constants';
import DiagnosticsStep from '../DiagnosticStep';
const stepName = 'Step-4';
const stepDescriptiveName = 'sessionDataValidation';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const cookies = await session.defaultSession.cookies.get({});
if (!cookies) {
logger.error(`${stepName}: No cookies found`);
throw new Error('No cookies found');
}
const userId = cookies.find((cookie) => cookie.name === COOKIE_NAME_USER_ID);
const csrf = cookies.find((cookie) => cookie.name === COOKIE_NAME_CSRF);
const authToken = cookies.find((cookie) => cookie.name === COOKIE_NAME_AUTH_TOKEN);
if (!userId || !csrf || !authToken) {
const errMessage = `Not all required cookies found. "userId": ${Boolean(userId)}, "csrf": ${Boolean(csrf)}, "authToken": ${Boolean(authToken)}`;
logger.error(`${stepName}: ${errMessage}`);
throw new Error(errMessage);
}
return {
message: `${stepName} finished successfully`,
succeeded: true,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step4 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step4;

View file

@ -0,0 +1,64 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import windowManager from 'main/windows/windowManager';
import DiagnosticsStep from '../DiagnosticStep';
import {browserWindowVisibilityStatus, webContentsCheck} from './internal/utils';
const stepName = 'Step-5';
const stepDescriptiveName = 'BrowserWindowsChecks';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
/** Main window check */
if (!windowManager.mainWindowReady) {
throw new Error('Main window not ready');
}
const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', windowManager.mainWindow);
const webContentsOk = webContentsCheck(windowManager.mainWindow?.webContents);
if (mainWindowVisibilityStatus.some((status) => !status.ok) || !webContentsOk) {
return {
message: `${stepName} failed`,
succeeded: false,
payload: {
message: 'Some checks failed for main window',
data: {
mainWindowVisibilityStatus,
webContentsOk,
},
},
};
}
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload: {
mainWindowVisibilityStatus,
webContentsOk,
},
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step5 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step5;

View file

@ -0,0 +1,64 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import {Notification, systemPreferences} from 'electron';
import log, {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
import config from 'common/config';
import {checkPathPermissions} from './internal/utils';
const stepName = 'Step-6';
const stepDescriptiveName = 'PermissionsCheck';
const isDarwin = process.platform === 'darwin';
const isWin32 = process.platform === 'win32';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const downloadsFileAccess = await checkPathPermissions(config.downloadLocation, fs.constants.W_OK);
const logsFileAccess = await checkPathPermissions(log.transports.file.getFile().path, fs.constants.W_OK);
const payload: Record<string, unknown> = {
notificationsSupported: Notification.isSupported(),
fileSystem: {
downloadsFileAccess,
logsFileAccess,
},
};
if (isDarwin || isWin32) {
if (isDarwin) {
payload.isTrustedAccessibilityClient = systemPreferences.isTrustedAccessibilityClient(false);
}
payload.mediaAccessStatus = {
mic: systemPreferences.getMediaAccessStatus('microphone'),
screen: systemPreferences.getMediaAccessStatus('screen'),
};
}
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step6 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step6;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'path';
import {app, powerMonitor} from 'electron';
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import DiagnosticsStep from '../DiagnosticStep';
import {dateTimeInFilename} from './internal/utils';
const stepName = 'Step-7';
const stepDescriptiveName = 'PerformanceAndMemory';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const heapSnapshotFilepath = path.join(app.getAppPath(), `heapSnapshots/heap_snap_${dateTimeInFilename()}.txt`);
const payload: Record<string, unknown> = {
process: {
creationTime: process.getCreationTime(),
heapStatistics: process.getHeapStatistics(),
blinkMemory: process.getBlinkMemoryInfo(),
processMemory: process.getProcessMemoryInfo(),
systemMemory: process.getSystemMemoryInfo(),
systemVersion: process.getSystemVersion(),
cpuUsage: process.getCPUUsage(),
heapSnapshot: {
path: heapSnapshotFilepath,
success: process.takeHeapSnapshot(heapSnapshotFilepath),
},
IOCounters: process.getIOCounters(),
uptime: process.uptime(),
platform: process.platform,
sandboxed: process.sandboxed,
contextIsolated: process.contextIsolated,
type: process.type,
versions: process.versions,
version: process.version,
mas: process.mas,
windowsStore: process.windowsStore,
},
onBattery: powerMonitor.onBatteryPower,
};
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step7 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step7;

View file

@ -0,0 +1,56 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import log, {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import {getPercentage} from 'main/utils';
import DiagnosticsStep from '../DiagnosticStep';
import {readFileLineByLine} from './internal/utils';
const stepName = 'Step-8';
const stepDescriptiveName = 'LogHeuristics';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const mainLogFilePath = log.transports.file.getFile().path;
const fileData = await readFileLineByLine(mainLogFilePath);
const linesCount = fileData.lines.length;
const percentageOfErrors = getPercentage(fileData.logLevelAmounts.error, linesCount);
/**
* Ideally we could define a threshold for the error % for which this step would return an appropriate message
* and/or return all the errors
*/
const payload = {
logLevels: fileData.logLevelAmounts,
percentageOfErrors,
linesCount,
};
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step8 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step8;

View file

@ -0,0 +1,39 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog} from 'electron-log';
import {DiagnosticStepResponse} from 'types/diagnostics';
import config from 'common/config';
import DiagnosticsStep from '../DiagnosticStep';
const stepName = 'Step-9';
const stepDescriptiveName = 'Config';
const run = async (logger: ElectronLog): Promise<DiagnosticStepResponse> => {
try {
const payload = config.data;
return {
message: `${stepName} finished successfully`,
succeeded: true,
payload,
};
} catch (error) {
logger.warn(`Diagnostics ${stepName} Failure`, {error});
return {
message: `${stepName} failed`,
succeeded: false,
payload: error,
};
}
};
const Step9 = new DiagnosticsStep({
name: `diagnostic-${stepName}: ${stepDescriptiveName}`,
retries: 0,
run,
});
export default Step9;

View file

@ -316,4 +316,10 @@ describe('main/menus/app', () => {
expect(menuItem).toBe(undefined);
}
});
it('should show the "Run diagnostics" item under help', () => {
const menu = createTemplate(config);
const helpSubmenu = menu.find((subMenu) => subMenu.id === 'help')?.submenu;
expect(helpSubmenu).toContainObject({id: 'diagnostics'});
});
});

View file

@ -14,6 +14,7 @@ import {localizeMessage} from 'main/i18nManager';
import WindowManager from 'main/windows/windowManager';
import {UpdateManager} from 'main/autoUpdater';
import downloadsManager from 'main/downloadsManager';
import Diagnostics from 'main/diagnostics';
export function createTemplate(config: Config, updateManager: UpdateManager) {
const separatorItem: MenuItemConstructorOptions = {
@ -339,6 +340,15 @@ export function createTemplate(config: Config, updateManager: UpdateManager) {
submenu.push(separatorItem);
}
submenu.push({
id: 'diagnostics',
label: localizeMessage('main.menus.app.help.RunDiagnostics', 'Run diagnostics'),
click() {
Diagnostics.run();
},
});
submenu.push(separatorItem);
const version = localizeMessage('main.menus.app.help.versionString', 'Version {version}{commit}', {
version: app.getVersion(),
// eslint-disable-next-line no-undef

View file

@ -4,6 +4,8 @@
import {net, session} from 'electron';
import log from 'electron-log';
import {COOKIE_NAME_AUTH_TOKEN, COOKIE_NAME_CSRF, COOKIE_NAME_USER_ID} from 'common/constants';
export async function getServerAPI<T>(url: URL, isAuthenticated: boolean, onSuccess?: (data: T) => void, onAbort?: () => void, onError?: (error: Error) => void) {
if (isAuthenticated) {
const cookies = await session.defaultSession.cookies.get({});
@ -15,9 +17,9 @@ export async function getServerAPI<T>(url: URL, isAuthenticated: boolean, onSucc
// Filter out cookies that aren't part of our domain
const filteredCookies = cookies.filter((cookie) => cookie.domain && url.toString().indexOf(cookie.domain) >= 0);
const userId = filteredCookies.find((cookie) => cookie.name === 'MMUSERID');
const csrf = filteredCookies.find((cookie) => cookie.name === 'MMCSRF');
const authToken = filteredCookies.find((cookie) => cookie.name === 'MMAUTHTOKEN');
const userId = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_USER_ID);
const csrf = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_CSRF);
const authToken = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_AUTH_TOKEN);
if (!userId || !csrf || !authToken) {
// Missing cookies needed for req

51
src/types/diagnostics.ts Normal file
View file

@ -0,0 +1,51 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ElectronLog, LogLevel} from 'electron-log';
export type DiagnosticsStepConstructorPayload = {
name: string;
retries: number;
run: (logger: ElectronLog) => Promise<DiagnosticStepResponse>;
}
export type DiagnosticStepResponse = {
succeeded: boolean;
message?: string;
payload?: unknown;
duration?: number;
}
export type DiagnosticsReportObject = DiagnosticStepResponse & {
step: number;
name: DiagnosticsStepConstructorPayload['name'];
}
export type AddDurationToFnReturnObject =
(run: (logger: ElectronLog) => Promise<DiagnosticStepResponse>)
=> (logger: ElectronLog)
=> Promise<Omit<DiagnosticStepResponse, 'duration'> & {duration: number}>;
export type DiagnosticsReport = DiagnosticsReportObject[];
export type WindowStatus = Array<{
name: string;
ok: boolean;
data?: unknown;
}>;
export type LogFileLineData = {
text: string;
logLevel?: LogLevel;
date?: string;
}
export type LogLevelAmounts = {
silly: number;
debug: number;
verbose: number;
info: number;
warn: number;
error: number;
}