diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index 9c51dac635..7d3d713b23 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -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: diff --git a/server/channels/api4/license.go b/server/channels/api4/license.go index 3bef081456..08257c492e 100644 --- a/server/channels/api4/license.go +++ b/server/channels/api4/license.go @@ -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)) + } +} diff --git a/server/channels/api4/license_test.go b/server/channels/api4/license_test.go index 3cde6a28d2..693cf08fac 100644 --- a/server/channels/api4/license_test.go +++ b/server/channels/api4/license_test.go @@ -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) + }) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 887d7c4d61..c85ecd4a3a 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 94e7767ffe..8c150e7255 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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 diff --git a/webapp/channels/src/components/about_build_modal/about_build_modal.test.tsx b/webapp/channels/src/components/about_build_modal/about_build_modal.test.tsx index ab039f7284..4c64b1d8f3 100644 --- a/webapp/channels/src/components/about_build_modal/about_build_modal.test.tsx +++ b/webapp/channels/src/components/about_build_modal/about_build_modal.test.tsx @@ -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; diff --git a/webapp/channels/src/components/about_build_modal/about_build_modal.tsx b/webapp/channels/src/components/about_build_modal/about_build_modal.tsx index 35464ba198..c7a798876e 100644 --- a/webapp/channels/src/components/about_build_modal/about_build_modal.tsx +++ b/webapp/channels/src/components/about_build_modal/about_build_modal.tsx @@ -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 { @@ -50,9 +53,26 @@ export default class AboutBuildModal extends React.PureComponent { 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 { ); } + let loadMetricComponent: JSX.Element | null = null; + if (this.state.loadMetric !== null && this.state.loadMetric > 0) { + loadMetricComponent = ( +
+ + {'\u00a0' + this.state.loadMetric} +
+ ); + } + return ( { {'\u00a0' + mmversion} + {loadMetricComponent}
server, desktop and mobile apps.", "about.privacy": "Privacy Policy", "about.serverDisconnected": "disconnected", diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 9f9d961bdc..dc2bfaf1c6 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( `${this.getPluginsRoute()}/marketplace/first_admin_visit`,