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

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

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