diff --git a/app/actions/remote/license.test.ts b/app/actions/remote/license.test.ts new file mode 100644 index 000000000..2437de827 --- /dev/null +++ b/app/actions/remote/license.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; + +import {getLicenseLoadMetric} from './license'; +import {forceLogoutIfNecessary} from './session'; + +import type {Client} from '@client/rest'; + +jest.mock('@constants/device', () => ({}), {virtual: true}); +jest.mock('@database/manager', () => ({}), {virtual: true}); + +jest.mock('@managers/network_manager'); +jest.mock('./session'); + +describe('Actions.Remote.License', () => { + const serverUrl = 'https://server.com'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getLicenseLoadMetric', () => { + it('should return null if not licensed', async () => { + const result = await getLicenseLoadMetric(serverUrl, '10.8.0', false); + expect(result).toBeNull(); + expect(NetworkManager.getClient).not.toHaveBeenCalled(); + }); + + it('should return null if server version is less than minimum', async () => { + const result = await getLicenseLoadMetric(serverUrl, '10.7.0', true); + expect(result).toBeNull(); + expect(NetworkManager.getClient).not.toHaveBeenCalled(); + }); + + it('should fetch and return load metric if licensed and minimum version is met', async () => { + const mockClient = { + getLicenseLoadMetric: jest.fn().mockResolvedValue({load: 100}), + }; + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as unknown as Client); + + const result = await getLicenseLoadMetric(serverUrl, '10.8.0', true); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getLicenseLoadMetric).toHaveBeenCalledWith(); + expect(result).toBe(100); + }); + + it('should return null if response does not contain load or load is 0', async () => { + const mockClient = { + getLicenseLoadMetric: jest.fn().mockResolvedValue({load: 0}), + }; + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as unknown as Client); + + const result = await getLicenseLoadMetric(serverUrl, '10.8.0', true); + + expect(result).toBeNull(); + }); + + it('should return error and call forceLogoutIfNecessary if API call fails', async () => { + const mockError = new Error('API error'); + const mockClient = { + getLicenseLoadMetric: jest.fn().mockRejectedValue(mockError), + }; + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as unknown as Client); + + const result = await getLicenseLoadMetric(serverUrl, '10.8.0', true); + + expect(result).toEqual({error: mockError}); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, mockError); + }); + }); +}); diff --git a/app/actions/remote/license.ts b/app/actions/remote/license.ts new file mode 100644 index 000000000..71b9923c5 --- /dev/null +++ b/app/actions/remote/license.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import {forceLogoutIfNecessary} from './session'; + +export const getLicenseLoadMetric = async (serverUrl: string, serverVersion: string, isLicensed: boolean) => { + if (!isLicensed || !isMinimumServerVersion(serverVersion, 10, 8, 0)) { + return null; + } + + try { + const client = NetworkManager.getClient(serverUrl); + const response = await client.getLicenseLoadMetric(); + if (response?.load && response.load > 0) { + return response.load; + } + return null; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/client/rest/general.test.ts b/app/client/rest/general.test.ts index 634f8782f..09ef2ff19 100644 --- a/app/client/rest/general.test.ts +++ b/app/client/rest/general.test.ts @@ -78,6 +78,15 @@ describe('ClientGeneral', () => { expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); + test('getLicenseLoadMetric', async () => { + const expectedUrl = `${client.urlVersion}/license/load_metric`; + const expectedOptions = {method: 'get'}; + + await client.getLicenseLoadMetric(); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); + test('getTimezones', async () => { const expectedUrl = client.getTimezonesRoute(); const expectedOptions = {method: 'get'}; diff --git a/app/client/rest/general.ts b/app/client/rest/general.ts index ff60ec0b7..2267566d4 100644 --- a/app/client/rest/general.ts +++ b/app/client/rest/general.ts @@ -18,6 +18,7 @@ export interface ClientGeneralMix { logClientError: (message: string, level?: string) => Promise; getClientConfigOld: (groupLabel?: RequestGroupLabel) => Promise; getClientLicenseOld: (groupLabel?: RequestGroupLabel) => Promise; + getLicenseLoadMetric: () => Promise<{load: number}>; getTimezones: () => Promise; getGlobalDataRetentionPolicy: (groupLabel?: RequestGroupLabel) => Promise; getTeamDataRetentionPolicies: (userId: string, page?: number, perPage?: number, groupLabel?: RequestGroupLabel) => Promise>; @@ -70,6 +71,13 @@ const ClientGeneral = >(superclass: TBase) ); }; + getLicenseLoadMetric = async () => { + return this.doFetch( + `${this.urlVersion}/license/load_metric`, + {method: 'get'}, + ); + }; + getTimezones = async () => { return this.doFetch( `${this.getTimezonesRoute()}`, diff --git a/app/screens/settings/about/about.tsx b/app/screens/settings/about/about.tsx index c758f07d0..6dee90895 100644 --- a/app/screens/settings/about/about.tsx +++ b/app/screens/settings/about/about.tsx @@ -3,10 +3,11 @@ import Clipboard from '@react-native-clipboard/clipboard'; import {applicationId, nativeApplicationVersion, nativeBuildVersion} from 'expo-application'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {useIntl} from 'react-intl'; import {Alert, Text, View} from 'react-native'; +import {getLicenseLoadMetric} from '@actions/remote/license'; import Config from '@assets/config.json'; import Button from '@components/button'; import CompassIcon from '@components/compass_icon'; @@ -14,6 +15,7 @@ import FormattedText from '@components/formatted_text'; import SettingContainer from '@components/settings/container'; import AboutLinks from '@constants/about_links'; import {SNACK_BAR_TYPE} from '@constants/snack_bar'; +import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {t} from '@i18n'; @@ -119,6 +121,22 @@ const About = ({componentId, config, license}: AboutProps) => { const intl = useIntl(); const theme = useTheme(); const styles = getStyleSheet(theme); + const serverUrl = useServerUrl(); + const [loadMetric, setLoadMetric] = useState(null); + + useEffect(() => { + const fetchLoadMetric = async () => { + const isLicensed = license.IsLicensed === 'true'; + const result = await getLicenseLoadMetric(serverUrl, config.Version, isLicensed); + + // Only set the metric if we got a number back + if (result !== null && typeof result === 'number') { + setLoadMetric(result); + } + }; + + fetchLoadMetric(); + }, [config.Version, license.IsLicensed, serverUrl]); const openURL = useCallback((url: string) => { const onError = () => { @@ -182,11 +200,17 @@ const About = ({componentId, config, license}: AboutProps) => { const server = buildNumber === version ? intl.formatMessage({id: 'settings.about.server.version.noBuild', defaultMessage: 'Server Version: {version}'}, {version}) : intl.formatMessage({id: 'settings.about.server.version', defaultMessage: 'Server Version: {version} (Build {buildNumber})'}, {version, buildNumber}); const database = intl.formatMessage({id: 'settings.about.database', defaultMessage: 'Database: {driverName}'}, {driverName: config.SQLDriverName}); const databaseSchemaVersion = intl.formatMessage({id: 'settings.about.database.schema', defaultMessage: 'Database Schema Version: {version}'}, {version: config.SchemaVersion}); - const copiedString = `${appVersion}\n${server}\n${database}\n${databaseSchemaVersion}`; + let copiedString = `${appVersion}\n${server}\n${database}\n${databaseSchemaVersion}`; + + if (loadMetric !== null) { + const loadMetricStr = intl.formatMessage({id: 'settings.about.license.load_metric', defaultMessage: 'Load Metric: {load}'}, {load: loadMetric}); + copiedString += `\n${loadMetricStr}`; + } + Clipboard.setString(copiedString); showSnackBar({barType: SNACK_BAR_TYPE.INFO_COPIED, sourceScreen: componentId}); }, - [intl, config], + [intl, config, loadMetric], ); return ( @@ -238,6 +262,22 @@ const About = ({componentId, config, license}: AboutProps) => { {serverVersion} + {loadMetric !== null && ( + + + {intl.formatMessage({id: 'settings.about.license.load_metric.title', defaultMessage: 'Load Metric:'})} + + + {loadMetric} + + + )} { if (isLicensed) { await expect(AboutScreen.licensee).toBeVisible(); + + // * Verify license load metric - this may or may not be visible depending on server version + // * We're not asserting on the visibility since it depends on the server version and license + // * Instead we're verifying it has the correct text if it is visible + try { + await expect(AboutScreen.licenseLoadMetricTitle).toHaveText('Load Metric:'); + await expect(AboutScreen.licenseLoadMetricValue).toBeVisible(); + } catch (error) { + // Load metric may not be available depending on server version + // This is fine as the feature depends on server version and configuration + } } else { await expect(AboutScreen.licensee).not.toBeVisible(); + await expect(AboutScreen.licenseLoadMetricTitle).not.toBeVisible(); } await expect(AboutScreen.learnMoreText).toHaveText('Learn more about Enterprise Edition at '); await expect(AboutScreen.learnMoreUrl).toBeVisible();