Skip to content

Commit

Permalink
MM-57120: show connected users stat (#571)
Browse files Browse the repository at this point in the history
Show the number of connected users on the system console for administrators.

Fixes: https://mattermost.atlassian.net/browse/MM-57350
  • Loading branch information
lieut-data authored Apr 1, 2024
1 parent a168e86 commit 45621d0
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 0 deletions.
33 changes: 33 additions & 0 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func NewAPI(p *Plugin, store store.Store) *API {
router.HandleFunc("/connected-users/download", api.getConnectedUsersFile).Methods(http.MethodGet)
router.HandleFunc("/notify-connect", api.notifyConnect).Methods("GET")
router.HandleFunc(APIChoosePrimaryPlatform, api.choosePrimaryPlatform).Methods(http.MethodGet)
router.HandleFunc("/stats/site", api.siteStats).Methods("GET")

// iFrame support
router.HandleFunc("/iframe/mattermostTab", api.iFrame).Methods("GET")
Expand Down Expand Up @@ -709,3 +710,35 @@ func GetPageAndPerPage(r *http.Request) (page, perPage int) {

return page, perPage
}

func (a *API) siteStats(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")

if !a.p.API.HasPermissionTo(userID, model.PermissionManageSystem) {
a.p.API.LogWarn("Insufficient permissions", "user_id", userID)
http.Error(w, "not able to authorize the user", http.StatusForbidden)
return
}

stats, err := a.p.store.GetStats()
if err != nil {
a.p.API.LogWarn("Failed to get site stats", "error", err.Error())
http.Error(w, "unable to get site stats", http.StatusInternalServerError)
return
}

siteStats := struct {
TotalConnectedUsers int64 `json:"total_connected_users"`
}{
TotalConnectedUsers: stats.ConnectedUsers,
}

data, err := json.Marshal(siteStats)
if err != nil {
a.p.API.LogWarn("Failed to marshal site stats", "error", err.Error())
http.Error(w, "unable to get site stats", http.StatusInternalServerError)
return
}

_, _ = w.Write(data)
}
110 changes: 110 additions & 0 deletions server/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -962,3 +962,113 @@ func TestGetConnectedUsersFile(t *testing.T) {
})
}
}

func TestGetSiteStats(t *testing.T) {
for _, test := range []struct {
Name string
SetupPlugin func(*plugintest.API)
SetupStore func(*storemocks.Store)
ExpectedResult string
ExpectedStatusCode int
}{
{
Name: "getSiteStats: Insufficient permissions for the user",
SetupPlugin: func(api *plugintest.API) {
api.On("HasPermissionTo", testutils.GetUserID(), model.PermissionManageSystem).Return(false).Times(1)
},
SetupStore: func(store *storemocks.Store) {},
ExpectedStatusCode: http.StatusForbidden,
ExpectedResult: "not able to authorize the user\n",
},
{
Name: "getSiteStats: Unable to get the list of connected users from the store",
SetupPlugin: func(api *plugintest.API) {
api.On("HasPermissionTo", testutils.GetUserID(), model.PermissionManageSystem).Return(true).Times(1)
},
SetupStore: func(store *storemocks.Store) {
store.On("GetStats").Return(nil, errors.New("failed")).Times(1)
},
ExpectedStatusCode: http.StatusInternalServerError,
ExpectedResult: "unable to get site stats\n",
},
{
Name: "getSiteStats: no connected users",
SetupPlugin: func(api *plugintest.API) {
api.On("HasPermissionTo", testutils.GetUserID(), model.PermissionManageSystem).Return(true).Times(1)
},
SetupStore: func(store *storemocks.Store) {
store.On("GetStats").Return(&storemodels.Stats{
ConnectedUsers: 0,
SyntheticUsers: 999,
LinkedChannels: 999,
}, nil).Times(1)
},
ExpectedStatusCode: http.StatusOK,
ExpectedResult: `{"total_connected_users":0}`,
},
{
Name: "getSiteStats: 1 connected user",
SetupPlugin: func(api *plugintest.API) {
api.On("HasPermissionTo", testutils.GetUserID(), model.PermissionManageSystem).Return(true).Times(1)
},
SetupStore: func(store *storemocks.Store) {
store.On("GetStats").Return(&storemodels.Stats{
ConnectedUsers: 1,
SyntheticUsers: 999,
LinkedChannels: 999,
}, nil).Times(1)
},
ExpectedStatusCode: http.StatusOK,
ExpectedResult: `{"total_connected_users":1}`,
},
{
Name: "getSiteStats: 10 connected users",
SetupPlugin: func(api *plugintest.API) {
api.On("HasPermissionTo", testutils.GetUserID(), model.PermissionManageSystem).Return(true).Times(1)
},
SetupStore: func(store *storemocks.Store) {
store.On("GetStats").Return(&storemodels.Stats{
ConnectedUsers: 10,
SyntheticUsers: 999,
LinkedChannels: 999,
}, nil).Times(1)
},
ExpectedStatusCode: http.StatusOK,
ExpectedResult: `{"total_connected_users":10}`,
},
} {
t.Run(test.Name, func(t *testing.T) {
assert := assert.New(t)
plugin := newTestPlugin(t)
if test.ExpectedResult != "" {
plugin.metricsService.(*metricsmocks.Metrics).On("IncrementHTTPErrors").Times(1)
}

mockAPI := &plugintest.API{}
testutils.MockLogs(mockAPI)

plugin.SetAPI(mockAPI)
defer mockAPI.AssertExpectations(t)

test.SetupPlugin(mockAPI)
test.SetupStore(plugin.store.(*storemocks.Store))

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/stats/site", nil)
r.Header.Add("Mattermost-User-Id", testutils.GetUserID())
plugin.ServeHTTP(nil, w, r)

result := w.Result()
defer result.Body.Close()

assert.NotNil(t, result)
assert.Equal(test.ExpectedStatusCode, result.StatusCode)

bodyBytes, err := io.ReadAll(result.Body)
assert.Nil(err)
if test.ExpectedResult != "" {
assert.Equal(test.ExpectedResult, string(bodyBytes))
}
})
}
}
12 changes: 12 additions & 0 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {ClientError} from 'mattermost-redux/client/client4';

import {id as pluginId} from './manifest';

export interface SiteStats {
total_connected_users: number;
}

class ClientClass {
url = '';

Expand All @@ -17,6 +21,14 @@ class ClientClass {
await this.doGet(`${this.url}/notify-connect`);
};

fetchSiteStats = async (): Promise<SiteStats | null> => {
const data = await this.doGet(`${this.url}/stats/site`);
if (!data) {
return null;
}
return data as SiteStats;
};

doGet = async (url: string, headers: {[key: string]: any} = {}) => {
headers['X-Timezone-Offset'] = new Date().getTimezoneOffset();

Expand Down
15 changes: 15 additions & 0 deletions webapp/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ export default class Plugin {
registry.registerUserSettings?.(getSettings(serverRoute, !settingsEnabled));
}
});

// Site statistics handler
if (registry.registerSiteStatisticsHandler) {
registry.registerSiteStatisticsHandler(async () => {
const siteStats = await Client.fetchSiteStats();
return {
msteams_connected_users: {
name: 'MS Teams: Connected Users',
id: 'msteams_connected_users',
icon: 'fa-users', // font-awesome-4.7.0 handler
value: siteStats?.total_connected_users,
},
};
});
}
}

userActivityWatch(): void {
Expand Down
1 change: 1 addition & 0 deletions webapp/src/types/mattermost-webapp/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface PluginRegistry {
registerRootComponent(component: React.ElementType)
registerAdminConsoleCustomSetting(key: string, component: React.ElementType)
registerUserSettings?(setting: PluginConfiguration)
registerSiteStatisticsHandler(handler: any)

// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
}

0 comments on commit 45621d0

Please sign in to comment.