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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
10bff401ee
Коммит
4a93939359
@@ -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`,
|
||||
|
||||
Ссылка в новой задаче
Block a user