MM-63728: Add license load metric endpoint and UI indicator (#30700)

* Add license load metric endpoint and UI indicator

Adds an API endpoint to calculate and return license usage as a load metric, and displays this metric in the About dialog. The metric is calculated as (MAU/licensed users)*100.

Additionally:
- Renamed function to be consistent with API endpoint name
- Added proper i18n strings for error messages and UI elements

* Fix TypeScript null check in about_build_modal.tsx

* MM-63728: Update OpenAPI documentation for license load metric

Update the OpenAPI documentation and code comments to correctly describe the license load metric calculation as using a multiplier of 1000 instead of percentage.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* MM-63728: Use float for license load metric calculation

Modify the license load metric calculation to use floats throughout the computation process while still returning an integer result. This maintains the existing API but improves the precision of the calculation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* improve tests manually

* Update server/channels/api4/license_test.go

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>

* Update server/channels/api4/license_test.go

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
Этот коммит содержится в:
Jesse Hallam
2025-04-17 17:29:46 -03:00
коммит произвёл GitHub
родитель 10bff401ee
Коммит 4a93939359
9 изменённых файлов: 337 добавлений и 1 удалений

Просмотреть файл

@@ -5,9 +5,11 @@ import React from 'react';
import type {ClientConfig, ClientLicense} from '@mattermost/types/config';
import {Client4} from 'mattermost-redux/client';
import AboutBuildModal from 'components/about_build_modal/about_build_modal';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import {AboutLinks} from 'utils/constants';
import AboutBuildModalCloud from './about_build_modal_cloud/about_build_modal_cloud';
@@ -38,11 +40,15 @@ describe('components/AboutBuildModal', () => {
connected: false,
serverHostname: '',
};
jest.restoreAllMocks();
});
beforeEach(() => {
mockDate(new Date(2017, 6, 1));
// Mock the license load metric API call for all tests to prevent errors
jest.spyOn(Client4, 'getLicenseLoadMetric').mockResolvedValue({load: 0});
config = {
BuildEnterpriseReady: 'true',
Version: '3.6.0',
@@ -215,6 +221,54 @@ describe('components/AboutBuildModal', () => {
expect(screen.getByRole('link', {name: 'Privacy Policy'})).not.toHaveAttribute('href', config?.PrivacyPolicyLink);
});
test('should show load metric when license is loaded and API returns data', async () => {
// Override the global mock for this specific test
jest.spyOn(Client4, 'getLicenseLoadMetric').mockResolvedValue({load: 75});
renderAboutBuildModal({
license: {
IsLicensed: 'true',
Company: 'Mattermost Inc',
},
});
await waitFor(() => {
expect(screen.getByTestId('aboutModalLoadMetric')).toBeInTheDocument();
expect(screen.getByTestId('aboutModalLoadMetric')).toHaveTextContent('Load Metric: 75');
});
});
test('should not show load metric when API returns zero', async () => {
// This uses the mock set in beforeEach that returns load: 0
renderAboutBuildModal();
// Wait for any async operations to complete
await waitFor(() => {
expect(Client4.getLicenseLoadMetric).toHaveBeenCalled();
});
expect(screen.queryByTestId('aboutModalLoadMetric')).not.toBeInTheDocument();
});
test('should handle API errors gracefully', async () => {
// Temporarily suppress console.error for this test
jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock the API call to throw an error
jest.spyOn(Client4, 'getLicenseLoadMetric').mockRejectedValue(new Error('API error'));
renderAboutBuildModal();
// Wait for the API call to be made
await waitFor(() => {
expect(Client4.getLicenseLoadMetric).toHaveBeenCalled();
});
// The error should be logged but not cause the component to crash
expect(console.error).toHaveBeenCalled();
expect(screen.queryByTestId('aboutModalLoadMetric')).not.toBeInTheDocument();
});
function renderAboutBuildModal(props = {}) {
const onExited = jest.fn();
const show = true;

Просмотреть файл

@@ -7,6 +7,8 @@ import {FormattedMessage} from 'react-intl';
import type {ClientConfig, ClientLicense} from '@mattermost/types/config';
import {Client4} from 'mattermost-redux/client';
import ExternalLink from 'components/external_link';
import Nbsp from 'components/html_entities/nbsp';
import MattermostLogo from 'components/widgets/icons/mattermost_logo';
@@ -42,6 +44,7 @@ type Props = {
type State = {
show: boolean;
loadMetric: number | null;
};
export default class AboutBuildModal extends React.PureComponent<Props, State> {
@@ -50,9 +53,26 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
this.state = {
show: true,
loadMetric: 0,
};
}
componentDidMount() {
const fetchLoadMetric = async () => {
try {
const result = await Client4.getLicenseLoadMetric();
if (result?.load) {
this.setState({loadMetric: result.load});
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching load metric:', e);
}
};
fetchLoadMetric();
}
doHide = () => {
this.setState({show: false});
this.props.onExited();
@@ -231,6 +251,19 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
);
}
let loadMetricComponent: JSX.Element | null = null;
if (this.state.loadMetric !== null && this.state.loadMetric > 0) {
loadMetricComponent = (
<div data-testid='aboutModalLoadMetric'>
<FormattedMessage
id='about.loadmetric'
defaultMessage='Load Metric:'
/>
<span>{'\u00a0' + this.state.loadMetric}</span>
</div>
);
}
return (
<Modal
dialogClassName='a11y__modal about-modal'
@@ -278,6 +311,7 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
{'\u00a0' + mmversion}
</span>
</div>
{loadMetricComponent}
<div data-testid='aboutModalDBVersionString'>
<FormattedMessage
id='about.dbversion'

Просмотреть файл

@@ -12,6 +12,7 @@
"about.hash": "Build Hash:",
"about.hashee": "EE Build Hash:",
"about.licensed": "Licensed to:",
"about.loadmetric": "Load Metric:",
"about.notice": "Mattermost is made possible by the open source software used in our <linkServer>server</linkServer>, <linkDesktop>desktop</linkDesktop> and <linkMobile>mobile</linkMobile> apps.",
"about.privacy": "Privacy Policy",
"about.serverDisconnected": "disconnected",

Просмотреть файл

@@ -2600,6 +2600,15 @@ export default class Client4 {
);
};
getLicenseLoadMetric = () => {
return this.doFetch<{
load: number;
}>(
`${this.getBaseRoute()}/license/load_metric`,
{method: 'get'},
);
};
setFirstAdminVisitMarketplaceStatus = async () => {
return this.doFetch<StatusOK>(
`${this.getPluginsRoute()}/marketplace/first_admin_visit`,