[MM-60086][MM-60610] Implement performanceMonitor, collect CPU/memory usage data and send via API (#3165)

* [MM-60086][MM-60610] Implement performanceMonitor, collect CPU/memory usage data and send via API

* Translations

* PR feedback

* Update api-types package
This commit is contained in:
Devin Binnie
2024-10-18 10:13:39 -04:00
committed by GitHub
parent 9d52ee7f41
commit 10295162e0
38 changed files with 590 additions and 6 deletions

View File

@@ -79,6 +79,8 @@ export type DesktopAPI = {
openCallsUserSettings: () => void; openCallsUserSettings: () => void;
onOpenCallsUserSettings: (listener: () => void) => () => void; onOpenCallsUserSettings: (listener: () => void) => () => void;
onSendMetrics: (listener: (metricsMap: Map<string, {cpu?: number; memory?: number}>) => void) => () => void;
// Utility // Utility
unregister: (channel: string) => void; unregister: (channel: string) => void;
} }

View File

@@ -65,5 +65,9 @@ export type DesktopAPI = {
onOpenStopRecordingModal: (listener: (channelID: string) => void) => () => void; onOpenStopRecordingModal: (listener: (channelID: string) => void) => () => void;
openCallsUserSettings: () => void; openCallsUserSettings: () => void;
onOpenCallsUserSettings: (listener: () => void) => () => void; onOpenCallsUserSettings: (listener: () => void) => () => void;
onSendMetrics: (listener: (metricsMap: Map<string, {
cpu?: number;
memory?: number;
}>) => void) => () => void;
unregister: (channel: string) => void; unregister: (channel: string) => void;
}; };

View File

@@ -1,12 +1,12 @@
{ {
"name": "@mattermost/desktop-api", "name": "@mattermost/desktop-api",
"version": "5.10.0-1", "version": "5.10.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@mattermost/desktop-api", "name": "@mattermost/desktop-api",
"version": "5.10.0-1", "version": "5.10.0-2",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^4.3.0 || ^5.0.0" "typescript": "^4.3.0 || ^5.0.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mattermost/desktop-api", "name": "@mattermost/desktop-api",
"version": "5.10.0-1", "version": "5.10.0-2",
"description": "Shared types for the Desktop App API provided to the Web App", "description": "Shared types for the Desktop App API provided to the Web App",
"keywords": [ "keywords": [
"mattermost" "mattermost"

View File

@@ -218,6 +218,8 @@
"renderer.components.settingsPage.downloadLocation.description": "Specify the folder where files will download.", "renderer.components.settingsPage.downloadLocation.description": "Specify the folder where files will download.",
"renderer.components.settingsPage.enableHardwareAcceleration": "Use GPU hardware acceleration", "renderer.components.settingsPage.enableHardwareAcceleration": "Use GPU hardware acceleration",
"renderer.components.settingsPage.enableHardwareAcceleration.description": "If enabled, {appName} UI is rendered more efficiently but can lead to decreased stability for some systems.", "renderer.components.settingsPage.enableHardwareAcceleration.description": "If enabled, {appName} UI is rendered more efficiently but can lead to decreased stability for some systems.",
"renderer.components.settingsPage.enableMetrics": "Send anonymous usage data to your configured servers",
"renderer.components.settingsPage.enableMetrics.description": "Sends usage data about the application and its performance to your configured servers that accept it.",
"renderer.components.settingsPage.flashWindow": "Flash taskbar icon when a new message is received", "renderer.components.settingsPage.flashWindow": "Flash taskbar icon when a new message is received",
"renderer.components.settingsPage.flashWindow.description": "If enabled, the taskbar icon will flash for a few seconds when a new message is received.", "renderer.components.settingsPage.flashWindow.description": "If enabled, the taskbar icon will flash for a few seconds when a new message is received.",
"renderer.components.settingsPage.flashWindow.description.linuxFunctionality": "This functionality may not work with all Linux window managers.", "renderer.components.settingsPage.flashWindow.description.linuxFunctionality": "This functionality may not work with all Linux window managers.",

View File

@@ -150,6 +150,7 @@ const configDataSchemaV3 = Joi.object<ConfigV3>({
alwaysClose: Joi.boolean(), alwaysClose: Joi.boolean(),
logLevel: Joi.string().default('info'), logLevel: Joi.string().default('info'),
appLanguage: Joi.string().allow(''), appLanguage: Joi.string().allow(''),
enableMetrics: Joi.boolean(),
}); });
// eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'}; // eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'};

View File

@@ -197,3 +197,7 @@ export const GET_NONCE = 'get-nonce';
export const DEVELOPER_MODE_UPDATED = 'developer-mode-updated'; export const DEVELOPER_MODE_UPDATED = 'developer-mode-updated';
export const IS_DEVELOPER_MODE_ENABLED = 'is-developer-mode-enabled'; export const IS_DEVELOPER_MODE_ENABLED = 'is-developer-mode-enabled';
export const GET_DEVELOPER_MODE_SETTING = 'get-developer-mode-setting'; export const GET_DEVELOPER_MODE_SETTING = 'get-developer-mode-setting';
export const METRICS_SEND = 'metrics-send';
export const METRICS_RECEIVE = 'metrics-receive';
export const METRICS_REQUEST = 'metrics-request';

View File

@@ -51,6 +51,7 @@ const defaultPreferences: ConfigV3 = {
downloadLocation: getDefaultDownloadLocation(), downloadLocation: getDefaultDownloadLocation(),
startInFullscreen: false, startInFullscreen: false,
logLevel: 'info', logLevel: 'info',
enableMetrics: true,
}; };
export default defaultPreferences; export default defaultPreferences;

View File

@@ -241,6 +241,10 @@ export class Config extends EventEmitter {
return this.combinedData?.appLanguage; return this.combinedData?.appLanguage;
} }
get enableMetrics() {
return this.combinedData?.enableMetrics;
}
/** /**
* Gets the servers from registry into the config object and reload * Gets the servers from registry into the config object and reload
* *

View File

@@ -27,5 +27,11 @@ export default function migrateConfigItems(config: Config) {
didMigrate = true; didMigrate = true;
} }
if (!migrationPrefs.getValue('enableMetrics')) {
config.enableMetrics = true;
migrationPrefs.setValue('enableMetrics', true);
didMigrate = true;
}
return didMigrate; return didMigrate;
} }

View File

@@ -77,7 +77,9 @@ jest.mock('electron', () => ({
handle: jest.fn(), handle: jest.fn(),
}, },
})); }));
jest.mock('main/performanceMonitor', () => ({
init: jest.fn(),
}));
jest.mock('main/i18nManager', () => ({ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
setLocale: jest.fn(), setLocale: jest.fn(),

View File

@@ -50,6 +50,7 @@ import i18nManager from 'main/i18nManager';
import NonceManager from 'main/nonceManager'; import NonceManager from 'main/nonceManager';
import {getDoNotDisturb} from 'main/notifications'; import {getDoNotDisturb} from 'main/notifications';
import parseArgs from 'main/ParseArgs'; import parseArgs from 'main/ParseArgs';
import PerformanceMonitor from 'main/performanceMonitor';
import PermissionsManager from 'main/permissionsManager'; import PermissionsManager from 'main/permissionsManager';
import Tray from 'main/tray/tray'; import Tray from 'main/tray/tray';
import TrustedOriginsStore from 'main/trustedOrigins'; import TrustedOriginsStore from 'main/trustedOrigins';
@@ -448,6 +449,10 @@ async function initializeAfterAppReady() {
AppVersionManager.lastAppVersion = app.getVersion(); AppVersionManager.lastAppVersion = app.getVersion();
handleMainWindowIsShown(); handleMainWindowIsShown();
// The metrics won't start collecting for another minute
// so we can assume if we start now everything should be loaded by the time we're done
PerformanceMonitor.init();
} }
function onUserActivityStatus(status: { function onUserActivityStatus(status: {

View File

@@ -0,0 +1,255 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {app, ipcMain, powerMonitor} from 'electron';
import {EMIT_CONFIGURATION, METRICS_RECEIVE, METRICS_REQUEST, METRICS_SEND} from 'common/communication';
import Config from 'common/config';
import {PerformanceMonitor} from './performanceMonitor';
jest.mock('electron', () => ({
app: {
getAppMetrics: jest.fn(),
},
ipcMain: {
on: jest.fn(),
off: jest.fn(),
},
powerMonitor: {
on: jest.fn(),
},
}));
jest.mock('common/config', () => ({
enableMetrics: true,
}));
describe('main/performanceMonitor', () => {
let makeWebContents;
beforeAll(() => {
jest.useFakeTimers();
jest.spyOn(global, 'setInterval');
jest.spyOn(global, 'clearInterval');
});
beforeEach(() => {
app.getAppMetrics.mockReturnValue([]);
let cb;
ipcMain.on.mockImplementation((channel, listener) => {
if (channel === METRICS_RECEIVE) {
cb = listener;
}
});
makeWebContents = (id, resolve) => ({
send: jest.fn().mockImplementation((channel, arg1, arg2) => {
if (channel === METRICS_REQUEST) {
cb({sender: {id}}, arg1, {serverId: arg2, cpu: id, memory: id * 100});
}
if (channel === METRICS_SEND) {
resolve(arg1);
}
}),
on: (_, listener) => listener(),
id,
});
});
afterEach(() => {
Config.enableMetrics = true;
});
it('should start and stop with config changes', () => {
let emitConfigCb;
ipcMain.on.mockImplementation((channel, listener) => {
if (channel === EMIT_CONFIGURATION) {
emitConfigCb = listener;
}
});
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
expect(setInterval).toHaveBeenCalled();
Config.enableMetrics = false;
emitConfigCb();
expect(clearInterval).toHaveBeenCalled();
Config.enableMetrics = true;
emitConfigCb();
expect(setInterval).toHaveBeenCalledTimes(2);
});
it('should start and stop with power monitor changes', () => {
const listeners = new Map();
powerMonitor.on.mockImplementation((channel, listener) => {
listeners.set(channel, listener);
});
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
expect(setInterval).toHaveBeenCalled();
listeners.get('suspend')();
expect(clearInterval).toHaveBeenCalled();
setInterval.mockClear();
clearInterval.mockClear();
listeners.get('resume')();
expect(setInterval).toHaveBeenCalled();
listeners.get('lock-screen')();
expect(clearInterval).toHaveBeenCalled();
setInterval.mockClear();
clearInterval.mockClear();
listeners.get('unlock-screen')();
expect(setInterval).toHaveBeenCalled();
listeners.get('speed-limit-change')(50);
expect(clearInterval).toHaveBeenCalled();
setInterval.mockClear();
clearInterval.mockClear();
listeners.get('speed-limit-change')(100);
expect(setInterval).toHaveBeenCalled();
});
describe('init', () => {
it('should not start until init', () => {
const performanceMonitor = new PerformanceMonitor();
expect(setInterval).not.toHaveBeenCalled();
performanceMonitor.init();
expect(setInterval).toHaveBeenCalled();
});
it('should run app metrics for node on init', () => {
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
expect(app.getAppMetrics).toHaveBeenCalled();
});
it('should not start if disabled by config', () => {
Config.enableMetrics = false;
const performanceMonitor = new PerformanceMonitor();
expect(setInterval).not.toHaveBeenCalled();
performanceMonitor.init();
expect(setInterval).not.toHaveBeenCalled();
});
});
describe('registerView', () => {
it('should send metrics to registered server views', async () => {
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
const sendValue = new Promise((resolve) => {
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
});
jest.runOnlyPendingTimers();
expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]]));
});
it('should send metrics for other tabs to registered server views', async () => {
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
const sendValue = new Promise((resolve) => {
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
performanceMonitor.registerView('view-2', makeWebContents(2, resolve), 'server-1');
});
jest.runOnlyPendingTimers();
expect(await sendValue).toEqual(new Map([['view-2', {cpu: 2, memory: 200, serverId: 'server-1'}], ['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]]));
});
it('should not send metrics for tabs of other servers to registered server views', async () => {
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
const sendValue = new Promise((resolve) => {
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
performanceMonitor.registerView('view-2', makeWebContents(2, resolve), 'server-2');
});
jest.runOnlyPendingTimers();
expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]]));
});
it('should always include node metrics', async () => {
app.getAppMetrics.mockReturnValue([{
name: 'main',
type: 'Browser',
cpu: {percentCPUUsage: 50},
memory: {privateBytes: 1000},
}]);
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
const sendValue = new Promise((resolve) => {
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
});
jest.runOnlyPendingTimers();
expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}], ['main', {cpu: 50, memory: 1000}]]));
});
it('should never include tabs from getAppMetrics', async () => {
app.getAppMetrics.mockReturnValue([{
name: 'other-server',
type: 'Tab',
cpu: {percentCPUUsage: 50},
memory: {privateBytes: 1000},
}]);
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
const sendValue = new Promise((resolve) => {
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
});
jest.runOnlyPendingTimers();
expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]]));
});
});
describe('unregisterView', () => {
it('should not send after the view is removed', async () => {
const performanceMonitor = new PerformanceMonitor();
performanceMonitor.init();
const sendValue = new Promise((resolve) => {
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
performanceMonitor.registerServerView('view-2', makeWebContents(2, resolve), 'server-1');
});
jest.runOnlyPendingTimers();
expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}], ['view-2', {cpu: 2, memory: 200, serverId: 'server-1'}]]));
// Have to re-register to make sure the promise resolves
const sendValue2 = new Promise((resolve) => {
performanceMonitor.unregisterView(2);
performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1');
});
jest.runOnlyPendingTimers();
expect(await sendValue2).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]]));
});
});
});

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {app, ipcMain, type IpcMainEvent, powerMonitor, type WebContents} from 'electron';
import {EMIT_CONFIGURATION, METRICS_RECEIVE, METRICS_REQUEST, METRICS_SEND} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
const METRIC_SEND_INTERVAL = 60000;
const log = new Logger('PerformanceMonitor');
type MetricsView = {
name: string;
webContents: WebContents;
serverId?: string;
}
type Metrics = {
serverId?: string;
cpu?: number;
memory?: number;
}
export class PerformanceMonitor {
private updateInterval?: NodeJS.Timeout;
private views: Map<number, MetricsView>;
private serverViews: Map<number, MetricsView>;
private isInitted: boolean;
constructor() {
this.views = new Map();
this.serverViews = new Map();
this.isInitted = false;
powerMonitor.on('suspend', this.stop);
powerMonitor.on('resume', this.start);
powerMonitor.on('lock-screen', this.stop);
powerMonitor.on('unlock-screen', this.start);
powerMonitor.on('speed-limit-change', this.handleSpeedLimitChange);
ipcMain.on(EMIT_CONFIGURATION, this.handleConfigUpdate);
}
init = () => {
// Set that it's initted so that the powerMonitor functions correctly
this.isInitted = true;
// Run once because the first CPU value is always 0
this.runMetrics();
if (Config.enableMetrics) {
this.start();
}
};
registerView = (name: string, webContents: WebContents, serverId?: string) => {
log.debug('registerView', webContents.id, name);
webContents.on('did-finish-load', () => {
this.views.set(webContents.id, {name, webContents, serverId});
});
};
registerServerView = (name: string, webContents: WebContents, serverId: string) => {
log.debug('registerServerView', webContents.id, serverId);
webContents.on('did-finish-load', () => {
this.serverViews.set(webContents.id, {name, webContents, serverId});
});
};
unregisterView = (webContentsId: number) => {
log.debug('unregisterView', webContentsId);
this.views.delete(webContentsId);
this.serverViews.delete(webContentsId);
};
private start = () => {
if (!this.isInitted) {
return;
}
if (!Config.enableMetrics) {
return;
}
log.verbose('start');
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
this.updateInterval = setInterval(this.sendMetrics, METRIC_SEND_INTERVAL);
};
private stop = () => {
log.verbose('stop');
clearInterval(this.updateInterval);
delete this.updateInterval;
};
private runMetrics = async () => {
const metricsMap: Map<string, Metrics> = new Map();
// Collect metrics for all of the Node processes
app.getAppMetrics().
filter((metric) => metric.type !== 'Tab').
forEach((metric) => {
metricsMap.set(metric.name ?? metric.type, {
cpu: metric.cpu.percentCPUUsage,
memory: metric.memory.privateBytes ?? metric.memory.workingSetSize,
});
});
const viewResolves: Map<number, () => void> = new Map();
const listener = (event: IpcMainEvent, name: string, metrics: Metrics) => {
metricsMap.set(name, metrics);
viewResolves.get(event.sender.id)?.();
};
ipcMain.on(METRICS_RECEIVE, listener);
const viewPromises = [...this.views.values(), ...this.serverViews.values()].map((view) => {
return new Promise<void>((resolve) => {
viewResolves.set(view.webContents.id, resolve);
view.webContents.send(METRICS_REQUEST, view.name, view.serverId);
});
});
// After 5 seconds, if all the promises are not resolved, resolve them so we don't block the send
// This can happen if a view doesn't send back metrics information
setTimeout(() => {
[...viewResolves.values()].forEach((value) => value());
}, 5000);
await Promise.allSettled(viewPromises);
ipcMain.off(METRICS_RECEIVE, listener);
return metricsMap;
};
private sendMetrics = async () => {
const metricsMap = await this.runMetrics();
for (const view of this.serverViews.values()) {
const serverId = view.serverId;
if (!serverId) {
log.error(`Cannot send metrics for ${view.name} - missing server id`);
continue;
}
if (!view.webContents) {
log.error(`Cannot send metrics for ${view.name} - missing web contents`);
continue;
}
const serverMetricsMap = new Map([...metricsMap].filter((value) => !value[1].serverId || value[1].serverId === view.serverId));
view.webContents.send(METRICS_SEND, serverMetricsMap);
}
};
private handleConfigUpdate = () => {
if (!Config.enableMetrics && this.updateInterval) {
this.stop();
} else if (!this.updateInterval) {
this.start();
}
};
private handleSpeedLimitChange = (limit: number) => {
if (limit < 100) {
this.stop();
} else {
this.start();
}
};
}
const performanceMonitor = new PerformanceMonitor();
export default performanceMonitor;

View File

@@ -44,6 +44,9 @@ import {
LEGACY_OFF, LEGACY_OFF,
TAB_LOGIN_CHANGED, TAB_LOGIN_CHANGED,
GET_DEVELOPER_MODE_SETTING, GET_DEVELOPER_MODE_SETTING,
METRICS_SEND,
METRICS_REQUEST,
METRICS_RECEIVE,
} from 'common/communication'; } from 'common/communication';
import type {ExternalAPI} from 'types/externalAPI'; import type {ExternalAPI} from 'types/externalAPI';
@@ -131,12 +134,22 @@ ipcRenderer.invoke(GET_DEVELOPER_MODE_SETTING, 'forceLegacyAPI').then((force) =>
openCallsUserSettings: () => ipcRenderer.send(CALLS_WIDGET_OPEN_USER_SETTINGS), openCallsUserSettings: () => ipcRenderer.send(CALLS_WIDGET_OPEN_USER_SETTINGS),
onOpenCallsUserSettings: (listener) => createListener(CALLS_WIDGET_OPEN_USER_SETTINGS, listener), onOpenCallsUserSettings: (listener) => createListener(CALLS_WIDGET_OPEN_USER_SETTINGS, listener),
onSendMetrics: (listener) => createListener(METRICS_SEND, listener),
// Utility // Utility
unregister: (channel) => ipcRenderer.removeAllListeners(channel), unregister: (channel) => ipcRenderer.removeAllListeners(channel),
}; };
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI); contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
}); });
ipcRenderer.on(METRICS_REQUEST, async (_, name, serverId) => {
const memory = await process.getProcessMemoryInfo();
ipcRenderer.send(METRICS_RECEIVE, name, {serverId, cpu: process.getCPUUsage().percentCPUUsage, memory: memory.residentSet ?? memory.private});
});
// Call this once to unset it to 0
process.getCPUUsage();
// Specific info for the testing environment // Specific info for the testing environment
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
contextBridge.exposeInMainWorld('testHelper', { contextBridge.exposeInMainWorld('testHelper', {

View File

@@ -93,6 +93,8 @@ import {
VIEW_FINISHED_RESIZING, VIEW_FINISHED_RESIZING,
GET_NONCE, GET_NONCE,
IS_DEVELOPER_MODE_ENABLED, IS_DEVELOPER_MODE_ENABLED,
METRICS_REQUEST,
METRICS_RECEIVE,
} from 'common/communication'; } from 'common/communication';
console.log('Preload initialized'); console.log('Preload initialized');
@@ -258,3 +260,10 @@ const createKeyDownListener = () => {
}; };
createKeyDownListener(); createKeyDownListener();
ipcRenderer.on(METRICS_REQUEST, async (_, name) => {
const memory = await process.getProcessMemoryInfo();
ipcRenderer.send(METRICS_RECEIVE, name, {cpu: process.getCPUUsage().percentCPUUsage, memory: memory.residentSet ?? memory.private});
});
// Call this once to unset it to 0
process.getCPUUsage();

View File

@@ -59,6 +59,9 @@ jest.mock('main/AutoLauncher', () => ({
jest.mock('main/badge', () => ({ jest.mock('main/badge', () => ({
setUnreadBadgeSetting: jest.fn(), setUnreadBadgeSetting: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
jest.mock('main/windows/mainWindow', () => ({ jest.mock('main/windows/mainWindow', () => ({
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
on: jest.fn(), on: jest.fn(),

View File

@@ -62,6 +62,11 @@ jest.mock('../utils', () => ({
jest.mock('main/developerMode', () => ({ jest.mock('main/developerMode', () => ({
get: jest.fn(), get: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
registerServerView: jest.fn(),
unregisterView: jest.fn(),
}));
const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'}); const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'});
const view = new MessagingView(server, true); const view = new MessagingView(server, true);

View File

@@ -23,8 +23,9 @@ import type {Logger} from 'common/log';
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants'; import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants';
import {isInternalURL, parseURL} from 'common/utils/url'; import {isInternalURL, parseURL} from 'common/utils/url';
import type {MattermostView} from 'common/views/View'; import {TAB_MESSAGING, type MattermostView} from 'common/views/View';
import DeveloperMode from 'main/developerMode'; import DeveloperMode from 'main/developerMode';
import performanceMonitor from 'main/performanceMonitor';
import {getServerAPI} from 'main/server/serverAPI'; import {getServerAPI} from 'main/server/serverAPI';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
@@ -196,6 +197,11 @@ export class MattermostBrowserView extends EventEmitter {
loadURL = this.view.url.toString(); loadURL = this.view.url.toString();
} }
this.log.verbose(`Loading ${loadURL}`); this.log.verbose(`Loading ${loadURL}`);
if (this.view.type === TAB_MESSAGING) {
performanceMonitor.registerServerView(`Server ${this.browserView.webContents.id}`, this.browserView.webContents, this.view.server.id);
} else {
performanceMonitor.registerView(`Server ${this.browserView.webContents.id}`, this.browserView.webContents, this.view.server.id);
}
const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent(DeveloperMode.get('browserOnly'))}); const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent(DeveloperMode.get('browserOnly'))});
loading.then(this.loadSuccess(loadURL)).catch((err) => { loading.then(this.loadSuccess(loadURL)).catch((err) => {
if (err.code && err.code.startsWith('ERR_CERT')) { if (err.code && err.code.startsWith('ERR_CERT')) {
@@ -262,6 +268,7 @@ export class MattermostBrowserView extends EventEmitter {
WebContentsEventManager.removeWebContentsListeners(this.webContentsId); WebContentsEventManager.removeWebContentsListeners(this.webContentsId);
AppState.clear(this.id); AppState.clear(this.id);
MainWindow.get()?.removeBrowserView(this.browserView); MainWindow.get()?.removeBrowserView(this.browserView);
performanceMonitor.unregisterView(this.browserView.webContents.id);
this.browserView.webContents.close(); this.browserView.webContents.close();
this.isVisible = false; this.isVisible = false;

View File

@@ -52,6 +52,9 @@ jest.mock('macos-notification-state', () => ({
getDoNotDisturb: jest.fn(), getDoNotDisturb: jest.fn(),
})); }));
jest.mock('main/downloadsManager', () => ({})); jest.mock('main/downloadsManager', () => ({}));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
jest.mock('main/windows/mainWindow', () => ({ jest.mock('main/windows/mainWindow', () => ({
on: jest.fn(), on: jest.fn(),
get: jest.fn(), get: jest.fn(),

View File

@@ -27,6 +27,7 @@ import {
TAB_BAR_HEIGHT, TAB_BAR_HEIGHT,
} from 'common/utils/constants'; } from 'common/utils/constants';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import performanceMonitor from 'main/performanceMonitor';
import {getLocalPreload} from 'main/utils'; import {getLocalPreload} from 'main/utils';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
@@ -75,6 +76,7 @@ export class DownloadsDropdownMenuView {
// @ts-ignore // @ts-ignore
transparent: true, transparent: true,
}}); }});
performanceMonitor.registerView('DownloadsDropdownMenuView', this.view.webContents);
this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdownMenu.html'); this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdownMenu.html');
MainWindow.get()?.addBrowserView(this.view); MainWindow.get()?.addBrowserView(this.view);
}; };

View File

@@ -65,6 +65,9 @@ jest.mock('main/downloadsManager', () => ({
onOpen: jest.fn(), onOpen: jest.fn(),
onClose: jest.fn(), onClose: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
jest.mock('main/windows/mainWindow', () => ({ jest.mock('main/windows/mainWindow', () => ({
on: jest.fn(), on: jest.fn(),
get: jest.fn(), get: jest.fn(),

View File

@@ -21,6 +21,7 @@ import Config from 'common/config';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants'; import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import performanceMonitor from 'main/performanceMonitor';
import {getLocalPreload} from 'main/utils'; import {getLocalPreload} from 'main/utils';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
@@ -65,6 +66,7 @@ export class DownloadsDropdownView {
transparent: true, transparent: true,
}}); }});
performanceMonitor.registerView('DownloadsDropdownView', this.view.webContents);
this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdown.html'); this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdown.html');
MainWindow.get()?.addBrowserView(this.view); MainWindow.get()?.addBrowserView(this.view);
}; };

View File

@@ -10,7 +10,9 @@ jest.mock('electron', () => ({
on: jest.fn(), on: jest.fn(),
}, },
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
jest.mock('main/windows/mainWindow', () => ({ jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(), get: jest.fn(),
on: jest.fn(), on: jest.fn(),

View File

@@ -5,6 +5,7 @@ import {BrowserView, app, ipcMain} from 'electron';
import {DARK_MODE_CHANGE, LOADING_SCREEN_ANIMATION_FINISHED, MAIN_WINDOW_RESIZED, TOGGLE_LOADING_SCREEN_VISIBILITY} from 'common/communication'; import {DARK_MODE_CHANGE, LOADING_SCREEN_ANIMATION_FINISHED, MAIN_WINDOW_RESIZED, TOGGLE_LOADING_SCREEN_VISIBILITY} from 'common/communication';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import performanceMonitor from 'main/performanceMonitor';
import {getLocalPreload, getWindowBoundaries} from 'main/utils'; import {getLocalPreload, getWindowBoundaries} from 'main/utils';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
@@ -86,6 +87,8 @@ export class LoadingScreen {
transparent: true, transparent: true,
}}); }});
const localURL = 'mattermost-desktop://renderer/loadingScreen.html'; const localURL = 'mattermost-desktop://renderer/loadingScreen.html';
performanceMonitor.registerView('LoadingScreen', this.view.webContents);
this.view.webContents.loadURL(localURL); this.view.webContents.loadURL(localURL);
}; };

View File

@@ -27,6 +27,10 @@ jest.mock('../contextMenu', () => jest.fn());
jest.mock('../utils', () => ({ jest.mock('../utils', () => ({
getWindowBoundaries: jest.fn(), getWindowBoundaries: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
unregisterView: jest.fn(),
}));
describe('main/views/modalView', () => { describe('main/views/modalView', () => {
describe('show', () => { describe('show', () => {

View File

@@ -5,6 +5,7 @@ import type {BrowserWindow} from 'electron';
import {BrowserView} from 'electron'; import {BrowserView} from 'electron';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import performanceMonitor from 'main/performanceMonitor';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getWindowBoundaries} from '../utils'; import {getWindowBoundaries} from '../utils';
@@ -50,6 +51,7 @@ export class ModalView<T, T2> {
this.status = Status.ACTIVE; this.status = Status.ACTIVE;
try { try {
performanceMonitor.registerView(`Modal-${key}`, this.view.webContents);
this.view.webContents.loadURL(this.html); this.view.webContents.loadURL(this.html);
} catch (e) { } catch (e) {
this.log.error('there was an error loading the modal:'); this.log.error('there was an error loading the modal:');
@@ -99,6 +101,7 @@ export class ModalView<T, T2> {
this.view.webContents.closeDevTools(); this.view.webContents.closeDevTools();
} }
this.windowAttached.removeBrowserView(this.view); this.windowAttached.removeBrowserView(this.view);
performanceMonitor.unregisterView(this.view.webContents.id);
this.view.webContents.close(); this.view.webContents.close();
delete this.windowAttached; delete this.windowAttached;

View File

@@ -29,6 +29,9 @@ jest.mock('electron', () => ({
getPath: jest.fn(() => '/valid/downloads/path'), getPath: jest.fn(() => '/valid/downloads/path'),
}, },
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
jest.mock('main/windows/mainWindow', () => ({ jest.mock('main/windows/mainWindow', () => ({
on: jest.fn(), on: jest.fn(),
get: jest.fn(), get: jest.fn(),

View File

@@ -22,6 +22,7 @@ import Config from 'common/config';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants';
import performanceMonitor from 'main/performanceMonitor';
import {getLocalPreload} from 'main/utils'; import {getLocalPreload} from 'main/utils';
import type {UniqueServer} from 'types/config'; import type {UniqueServer} from 'types/config';
@@ -83,6 +84,7 @@ export class ServerDropdownView {
// @ts-ignore // @ts-ignore
transparent: true, transparent: true,
}}); }});
performanceMonitor.registerView('ServerDropdownView', this.view.webContents);
this.view.webContents.loadURL('mattermost-desktop://renderer/dropdown.html'); this.view.webContents.loadURL('mattermost-desktop://renderer/dropdown.html');
this.setOrderedServers(); this.setOrderedServers();

View File

@@ -82,6 +82,9 @@ jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(), get: jest.fn(),
on: jest.fn(), on: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
jest.mock('common/servers/serverManager', () => ({ jest.mock('common/servers/serverManager', () => ({
getOrderedTabsForServer: jest.fn(), getOrderedTabsForServer: jest.fn(),
getAllServers: jest.fn(), getAllServers: jest.fn(),

View File

@@ -48,6 +48,7 @@ import {TAB_MESSAGING} from 'common/views/View';
import {flushCookiesStore} from 'main/app/utils'; import {flushCookiesStore} from 'main/app/utils';
import DeveloperMode from 'main/developerMode'; import DeveloperMode from 'main/developerMode';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import performanceMonitor from 'main/performanceMonitor';
import PermissionsManager from 'main/permissionsManager'; import PermissionsManager from 'main/permissionsManager';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
@@ -373,6 +374,7 @@ export class ViewManager {
transparent: true, transparent: true,
}}); }});
const localURL = `mattermost-desktop://renderer/urlView.html?url=${encodeURIComponent(urlString)}`; const localURL = `mattermost-desktop://renderer/urlView.html?url=${encodeURIComponent(urlString)}`;
performanceMonitor.registerView('URLView', urlView.webContents);
urlView.webContents.loadURL(localURL); urlView.webContents.loadURL(localURL);
MainWindow.get()?.addBrowserView(urlView); MainWindow.get()?.addBrowserView(urlView);
const boundaries = this.views.get(this.currentView || '')?.getBounds() ?? MainWindow.getBounds(); const boundaries = this.views.get(this.currentView || '')?.getBounds() ?? MainWindow.getBounds();
@@ -385,6 +387,7 @@ export class ViewManager {
log.error('Failed to remove URL view', e); log.error('Failed to remove URL view', e);
} }
performanceMonitor.unregisterView(urlView.webContents.id);
urlView.webContents.close(); urlView.webContents.close();
}; };

View File

@@ -67,6 +67,10 @@ jest.mock('main/windows/mainWindow', () => ({
jest.mock('app/serverViewState', () => ({ jest.mock('app/serverViewState', () => ({
switchServer: jest.fn(), switchServer: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
unregisterView: jest.fn(),
}));
jest.mock('main/views/viewManager', () => ({ jest.mock('main/views/viewManager', () => ({
getView: jest.fn(), getView: jest.fn(),
getViewByWebContentsId: jest.fn(), getViewByWebContentsId: jest.fn(),
@@ -156,6 +160,9 @@ describe('main/windows/callsWidgetWindow', () => {
on: jest.fn(), on: jest.fn(),
close: jest.fn(), close: jest.fn(),
isDestroyed: jest.fn(), isDestroyed: jest.fn(),
webContents: {
id: 1,
},
}; };
beforeEach(() => { beforeEach(() => {

View File

@@ -28,6 +28,7 @@ import {Logger} from 'common/log';
import {CALLS_PLUGIN_ID, MINIMUM_CALLS_WIDGET_HEIGHT, MINIMUM_CALLS_WIDGET_WIDTH} from 'common/utils/constants'; import {CALLS_PLUGIN_ID, MINIMUM_CALLS_WIDGET_HEIGHT, MINIMUM_CALLS_WIDGET_WIDTH} from 'common/utils/constants';
import {getFormattedPathName, isCallsPopOutURL, parseURL} from 'common/utils/url'; import {getFormattedPathName, isCallsPopOutURL, parseURL} from 'common/utils/url';
import Utils from 'common/utils/util'; import Utils from 'common/utils/util';
import performanceMonitor from 'main/performanceMonitor';
import PermissionsManager from 'main/permissionsManager'; import PermissionsManager from 'main/permissionsManager';
import { import {
composeUserAgent, composeUserAgent,
@@ -173,6 +174,7 @@ export class CallsWidgetWindow {
if (!widgetURL) { if (!widgetURL) {
return; return;
} }
performanceMonitor.registerView('CallsWidgetWindow', this.win.webContents);
this.win?.loadURL(widgetURL, { this.win?.loadURL(widgetURL, {
userAgent: composeUserAgent(), userAgent: composeUserAgent(),
}).catch((reason) => { }).catch((reason) => {
@@ -195,6 +197,7 @@ export class CallsWidgetWindow {
return; return;
} }
this.win?.on('closed', resolve); this.win?.on('closed', resolve);
performanceMonitor.unregisterView(this.win.webContents.id);
this.win?.close(); this.win?.close();
}); });
}; };

View File

@@ -73,6 +73,9 @@ jest.mock('../utils', () => ({
jest.mock('main/i18nManager', () => ({ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));
jest.mock('main/performanceMonitor', () => ({
registerView: jest.fn(),
}));
describe('main/windows/mainWindow', () => { describe('main/windows/mainWindow', () => {
describe('init', () => { describe('init', () => {

View File

@@ -34,6 +34,7 @@ import Utils from 'common/utils/util';
import * as Validator from 'common/Validator'; import * as Validator from 'common/Validator';
import {boundsInfoPath} from 'main/constants'; import {boundsInfoPath} from 'main/constants';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import performanceMonitor from 'main/performanceMonitor';
import type {SavedWindowState} from 'types/mainWindow'; import type {SavedWindowState} from 'types/mainWindow';
@@ -152,6 +153,7 @@ export class MainWindow extends EventEmitter {
contextMenu.reload(); contextMenu.reload();
const localURL = 'mattermost-desktop://renderer/index.html'; const localURL = 'mattermost-desktop://renderer/index.html';
performanceMonitor.registerView('MainWindow', this.win.webContents);
this.win.loadURL(localURL).catch( this.win.loadURL(localURL).catch(
(reason) => { (reason) => {
log.error('failed to load', reason); log.error('failed to load', reason);

View File

@@ -67,6 +67,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
autoCheckForUpdatesRef: React.RefObject<HTMLInputElement>; autoCheckForUpdatesRef: React.RefObject<HTMLInputElement>;
logLevelRef: React.RefObject<HTMLSelectElement>; logLevelRef: React.RefObject<HTMLSelectElement>;
appLanguageRef: React.RefObject<HTMLSelectElement>; appLanguageRef: React.RefObject<HTMLSelectElement>;
enableMetricsRef: React.RefObject<HTMLInputElement>;
saveQueue: SaveQueueItem[]; saveQueue: SaveQueueItem[];
@@ -106,6 +107,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
this.autoCheckForUpdatesRef = React.createRef(); this.autoCheckForUpdatesRef = React.createRef();
this.logLevelRef = React.createRef(); this.logLevelRef = React.createRef();
this.appLanguageRef = React.createRef(); this.appLanguageRef = React.createRef();
this.enableMetricsRef = React.createRef();
this.saveQueue = []; this.saveQueue = [];
this.selectedSpellCheckerLocales = []; this.selectedSpellCheckerLocales = [];
@@ -218,6 +220,13 @@ class SettingsPage extends React.PureComponent<Props, State> {
}, 2000); }, 2000);
}; };
handleEnableMetrics = () => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'enableMetrics', data: this.enableMetricsRef.current?.checked});
this.setState({
enableMetrics: this.enableMetricsRef.current?.checked,
});
};
handleChangeShowTrayIcon = () => { handleChangeShowTrayIcon = () => {
const shouldShowTrayIcon = this.showTrayIconRef.current?.checked; const shouldShowTrayIcon = this.showTrayIconRef.current?.checked;
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showTrayIcon', data: shouldShowTrayIcon}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showTrayIcon', data: shouldShowTrayIcon});
@@ -709,6 +718,30 @@ class SettingsPage extends React.PureComponent<Props, State> {
</FormCheck>); </FormCheck>);
} }
options.push(
<FormCheck
key='enableMetrics'
>
<FormCheck.Input
type='checkbox'
key='inputEnableMetrics'
id='inputEnableMetrics'
ref={this.enableMetricsRef}
checked={this.state.enableMetrics}
onChange={this.handleEnableMetrics}
/>
<FormattedMessage
id='renderer.components.settingsPage.enableMetrics'
defaultMessage='Send anonymous usage data to your configured servers'
/>
<FormText>
<FormattedMessage
id='renderer.components.settingsPage.enableMetrics.description'
defaultMessage='Sends usage data about the application and its performance to your configured servers that accept it.'
/>
</FormText>
</FormCheck>);
if (window.process.platform === 'win32' || window.process.platform === 'linux') { if (window.process.platform === 'win32' || window.process.platform === 'linux') {
options.push( options.push(
<FormCheck> <FormCheck>

View File

@@ -59,6 +59,7 @@ export type ConfigV3 = {
alwaysClose?: boolean; alwaysClose?: boolean;
logLevel?: string; logLevel?: string;
appLanguage?: string; appLanguage?: string;
enableMetrics?: boolean;
} }
export type ConfigV2 = export type ConfigV2 =
@@ -131,4 +132,5 @@ export type MigrationInfo = {
updateTrayIconWin32: boolean; updateTrayIconWin32: boolean;
masConfigs: boolean; masConfigs: boolean;
closeExtraTabs: boolean; closeExtraTabs: boolean;
enableMetrics: boolean;
} }

View File

@@ -25,4 +25,5 @@ export interface ExternalAPI {
createListener(event: 'calls-widget-open-thread', listener: (threadID: string) => void): () => void; createListener(event: 'calls-widget-open-thread', listener: (threadID: string) => void): () => void;
createListener(event: 'calls-widget-open-stop-recording-modal', listener: (channelID: string) => void): () => void; createListener(event: 'calls-widget-open-stop-recording-modal', listener: (channelID: string) => void): () => void;
createListener(event: 'calls-widget-open-user-settings', listener: () => void): () => void; createListener(event: 'calls-widget-open-user-settings', listener: () => void): () => void;
createListener(event: 'metrics-send', listener: (metricsMap: Map<string, {cpu?: number; memory?: number}>) => void): () => void;
} }