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 удалений

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

@@ -611,6 +611,37 @@
$ref: "#/components/responses/BadRequest"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/license/load_metric:
get:
tags:
- system
summary: Get license load metric
description: >
Get the current license load metric, calculated based on monthly active users
against the licensed user count. Returns a value of 0 when there is no license loaded or
the license doesn't have a user count.
__Minimum server version__: 10.8
##### Permissions
Must be logged in.
operationId: GetLicenseLoadMetric
responses:
"200":
description: License load metric retrieval successful
content:
application/json:
schema:
type: object
properties:
load:
type: integer
description: Current license load metric as an integer
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/license/renewal:
get:
tags:

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

@@ -7,12 +7,14 @@ import (
"bytes"
"encoding/json"
"io"
"math"
"net/http"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/audit"
)
@@ -22,6 +24,7 @@ func (api *API) InitLicense() {
api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(addLicense, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(removeLicense)).Methods(http.MethodDelete)
api.BaseRoutes.APIRoot.Handle("/license/client", api.APIHandler(getClientLicense)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/license/load_metric", api.APISessionRequired(getLicenseLoadMetric)).Methods(http.MethodGet)
}
func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -257,3 +260,34 @@ func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
// getLicenseLoadMetric returns a load metric computed as (mau / licensed) * 1000.
func getLicenseLoadMetric(c *Context, w http.ResponseWriter, r *http.Request) {
var loadMetric int
var licenseUsers int
license := c.App.Srv().License()
if license != nil && license.Features != nil {
licenseUsers = *license.Features.Users
}
if licenseUsers > 0 {
monthlyActiveUsers, err := c.App.Srv().Store().User().AnalyticsActiveCount(app.MonthMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false})
if err != nil {
c.Err = model.NewAppError("getLicenseLoad", "api.license.load_metric.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
loadMetric = int(math.Round((float64(monthlyActiveUsers) / float64(licenseUsers) * float64(1000))))
}
// Create response object
data := map[string]int{
"load": loadMetric,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

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

@@ -472,3 +472,155 @@ func TestRequestTrialLicense(t *testing.T) {
CheckForbiddenStatus(t, resp)
})
}
func TestGetLicenseLoadMetric(t *testing.T) {
t.Run("when user is logged out", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
client := th.CreateClient()
_, resp, err := client.GetLicenseLoadMetric(context.Background())
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("when no license is loaded", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
th.App.Srv().Platform().SetLicense(nil)
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, 0, data["load"])
})
t.Run("with 50 users on a license count of 1000", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
// Create a license with 1000 users
license := model.NewTestLicense()
license.Features.Users = model.NewPointer(1000) // Set license for 1000 users
th.App.Srv().Platform().SetLicense(license)
// Make user active by setting their status
status := &model.Status{
UserId: th.BasicUser.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
initialErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, initialErr)
// Add 50 active users (50/1000 * 1000 = 50)
for i := 0; i < 49; i++ { // 49 + 1 basic user = 50 active users
user := th.CreateUser()
// Make user active
status := &model.Status{
UserId: user.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
statusErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, statusErr)
}
// Check load metric - should be exactly 50
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
loadValue := data["load"]
require.Equal(t, 50, loadValue)
})
t.Run("with 19 users on a license count of 20", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
// Create a license with 20 users
license := model.NewTestLicense()
license.Features.Users = model.NewPointer(20) // Set license for 20 users
th.App.Srv().Platform().SetLicense(license)
// Make user active by setting their status
status := &model.Status{
UserId: th.BasicUser.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
initialErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, initialErr)
// Add 19 active users (19/20 * 1000 = 950)
for i := 0; i < 18; i++ { // 18 + 1 basic user = 19 active users
user := th.CreateUser()
// Make user active
status := &model.Status{
UserId: user.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
statusErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, statusErr)
}
// Check load metric - should be around
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
loadValue := data["load"]
require.Equal(t, 950, loadValue)
})
t.Run("with 30 users on a license count of 20", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
// Create a license with 20 users
license := model.NewTestLicense()
license.Features.Users = model.NewPointer(20) // Set license for 20 users
th.App.Srv().Platform().SetLicense(license)
// Make user active by setting their status
status := &model.Status{
UserId: th.BasicUser.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
initialErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, initialErr)
// Add 30 active users (30/20 * 1000 = 1500)
for i := 0; i < 29; i++ { // 29 + 1 basic user = 30 active users
user := th.CreateUser()
// Make user active
status := &model.Status{
UserId: user.Id,
Status: model.StatusAway,
Manual: true,
LastActivityAt: model.GetMillis(),
}
statusErr := th.App.Srv().Store().Status().SaveOrUpdate(status)
require.NoError(t, statusErr)
}
// Check load metric - should be exactly 1500
data, resp, err := th.Client.GetLicenseLoadMetric(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
loadValue := data["load"]
require.Equal(t, 1500, loadValue)
})
}

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

@@ -2328,6 +2328,10 @@
"id": "api.license.client.old_format.app_error",
"translation": "New format for the client license is not supported yet. Please specify format=old in the query string."
},
{
"id": "api.license.load_metric.app_error",
"translation": "Failed to compute monthly active users."
},
{
"id": "api.license.remove_expired_license.failed.error",
"translation": "Failed to send the disable license email successfully."

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

@@ -5091,6 +5091,23 @@ func (c *Client4) RemoveLicenseFile(ctx context.Context) (*Response, error) {
return BuildResponse(r), nil
}
// GetLicenseLoadMetric retrieves the license load metric from the server.
// The load is calculated as (monthly active users / licensed users) * 1000.
func (c *Client4) GetLicenseLoadMetric(ctx context.Context) (map[string]int, *Response, error) {
r, err := c.DoAPIGet(ctx, c.licenseRoute()+"/load_metric", "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var loadData map[string]int
if err := json.NewDecoder(r.Body).Decode(&loadData); err != nil {
return nil, BuildResponse(r), NewAppError("GetLicenseLoadMetric", "api.unmarshal_error", nil, "", r.StatusCode).Wrap(err)
}
return loadData, BuildResponse(r), nil
}
// GetAnalyticsOld will retrieve analytics using the old format. New format is not
// available but the "/analytics" endpoint is reserved for it. The "name" argument is optional
// and defaults to "standard". The "teamId" argument is optional and will limit results

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

@@ -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`,