From a8b18ac8076c7ae16d3ceb9a8ada7734ce206011 Mon Sep 17 00:00:00 2001 From: Arya Khochare <91268931+Aryakoste@users.noreply.github.com> Date: Mon, 24 Jun 2024 23:35:23 +0530 Subject: [PATCH] MM-57013 Added download button for downloading logs from server logs page in system console (#26389) * added download system logs * download all logs * download all logs check-lint fix * check lint fix * download logs api * download logs api working * download logs working with error log * linting issues and code cleanup * CI check fix * documented the api and logs from file with error handling * test and final changes done * final changes done * Fix order of server-side translations * Fix incorrect indentation of logs.yaml --------- Co-authored-by: Harrison Healey --- api/Makefile | 1 + api/v4/source/logs.yaml | 18 +++++ server/channels/api4/system.go | 31 +++++++++ server/channels/api4/system_test.go | 40 +++++++++++ server/channels/app/app_iface.go | 1 + .../app/opentracing/opentracing_layer.go | 22 ++++++ server/channels/app/support_packet.go | 4 +- server/channels/app/support_packet_test.go | 6 +- server/i18n/en.json | 4 ++ server/public/model/client4.go | 14 ++++ .../admin_console/server_logs/logs.tsx | 67 +++++++++++++------ webapp/channels/src/i18n/en.json | 1 + .../src/action_types/admin.ts | 1 + .../src/reducers/entities/admin.ts | 2 +- .../src/sass/routes/_admin-console.scss | 1 + 15 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 api/v4/source/logs.yaml diff --git a/api/Makefile b/api/Makefile index 6eda18a111..ea4a2abc52 100644 --- a/api/Makefile +++ b/api/Makefile @@ -53,6 +53,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/bookmarks.yaml >> $(V4_YAML) @cat $(V4_SRC)/reports.yaml >> $(V4_YAML) @cat $(V4_SRC)/limits.yaml >> $(V4_YAML) + @cat $(V4_SRC)/logs.yaml >> $(V4_YAML) @cat $(V4_SRC)/outgoing_oauth_connections.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi diff --git a/api/v4/source/logs.yaml b/api/v4/source/logs.yaml new file mode 100644 index 0000000000..4c2593021c --- /dev/null +++ b/api/v4/source/logs.yaml @@ -0,0 +1,18 @@ + /api/v4/logs/download: + get: + tags: + - logs + summary: Download system logs + description: > + Downloads the system logs as a text file. + operationId: DownloadSystemLogs + responses: + "200": + description: System logs downloaded successfully. + content: + text/plain: + schema: + type: string + format: binary + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/server/channels/api4/system.go b/server/channels/api4/system.go index 6f64d0717d..c6837816b4 100644 --- a/server/channels/api4/system.go +++ b/server/channels/api4/system.go @@ -50,6 +50,7 @@ func (api *API) InitSystem() { api.BaseRoutes.APIRoot.Handle("/caches/invalidate", api.APISessionRequired(invalidateCaches)).Methods("POST") api.BaseRoutes.APIRoot.Handle("/logs", api.APISessionRequired(getLogs)).Methods("GET") + api.BaseRoutes.APIRoot.Handle("/logs/download", api.APISessionRequired(downloadLogs)).Methods("GET") api.BaseRoutes.APIRoot.Handle("/logs/query", api.APISessionRequired(queryLogs)).Methods("POST") api.BaseRoutes.APIRoot.Handle("/logs", api.APIHandler(postLog)).Methods("POST") @@ -414,6 +415,36 @@ func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.ArrayToJSON(lines))) } +func downloadLogs(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("downloadLogs", audit.Fail) + defer c.LogAuditRec(auditRec) + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("downloadLogs", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionGetLogs) { + c.SetPermissionError(model.PermissionGetLogs) + return + } + + fileData, err := c.App.GetMattermostLog(c.AppContext) + if err != nil { + c.Err = model.NewAppError("downloadLogs", "api.system.logs.download_bytes_buffer.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + reader := bytes.NewReader(fileData.Body) + web.WriteFileResponse("mattermost.log", + "text/plain", + int64(len(fileData.Body)), + time.Now(), + *c.App.Config().ServiceSettings.WebserverMode, + reader, + true, + w, + r) +} + func postLog(c *Context, w http.ResponseWriter, r *http.Request) { forceToDebug := false diff --git a/server/channels/api4/system_test.go b/server/channels/api4/system_test.go index 9e6901cd8d..4c5c280727 100644 --- a/server/channels/api4/system_test.go +++ b/server/channels/api4/system_test.go @@ -402,6 +402,46 @@ func TestGetLogs(t *testing.T) { CheckUnauthorizedStatus(t, resp) } +func TestDownloadLogs(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + for i := 0; i < 20; i++ { + th.TestLogger.Info(strconv.Itoa(i)) + } + err := th.TestLogger.Flush() + require.NoError(t, err, "failed to flush log") + + t.Run("Download Logs as system admin", func(t *testing.T) { + resData, resp, err2 := th.SystemAdminClient.DownloadLogs(context.Background()) + require.NoError(t, err2) + + require.Equal(t, "text/plain", resp.Header.Get("Content-Type")) + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;filename=\"mattermost.log\"") + + bodyString := string(resData) + for i := 0; i < 20; i++ { + assert.Contains(t, bodyString, fmt.Sprintf(`"msg":"%d"`, i)) + } + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + _, resp, err2 := th.Client.DownloadLogs(context.Background()) + require.Error(t, err2) + CheckForbiddenStatus(t, resp) + }) + + _, resp, err := th.Client.DownloadLogs(context.Background()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + th.Client.Logout(context.Background()) + _, resp, err = th.Client.DownloadLogs(context.Background()) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) +} + func TestPostLog(t *testing.T) { th := Setup(t) defer th.TearDown() diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 83ea25b1bd..aa34418da1 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -728,6 +728,7 @@ type AppIface interface { GetLatestVersion(rctx request.CTX, latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) GetLogsSkipSend(rctx request.CTX, page, perPage int, logFilter *model.LogFilter) ([]string, *model.AppError) + GetMattermostLog(ctx request.CTX) (*model.FileData, error) GetMemberCountsByGroup(rctx request.CTX, channelID string, includeTimezones bool) ([]*model.ChannelMemberCountByGroup, *model.AppError) GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string GetMultipleEmojiByName(c request.CTX, names []string) ([]*model.Emoji, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 4665d158eb..827ff0cc02 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -7616,6 +7616,28 @@ func (a *OpenTracingAppLayer) GetMarketplacePlugins(rctx request.CTX, filter *mo return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetMattermostLog(ctx request.CTX) (*model.FileData, error) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetMattermostLog") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetMattermostLog(ctx) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetMemberCountsByGroup(rctx request.CTX, channelID string, includeTimezones bool) ([]*model.ChannelMemberCountByGroup, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetMemberCountsByGroup") diff --git a/server/channels/app/support_packet.go b/server/channels/app/support_packet.go index 1e15d49819..6ea0b1c5af 100644 --- a/server/channels/app/support_packet.go +++ b/server/channels/app/support_packet.go @@ -44,7 +44,7 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO } if options.IncludeLogs { - functions["mattermost log"] = a.getMattermostLog + functions["mattermost log"] = a.GetMattermostLog functions["notification log"] = a.getNotificationsLog } @@ -321,7 +321,7 @@ func (a *App) getNotificationsLog(_ request.CTX) (*model.FileData, error) { return fileData, nil } -func (a *App) getMattermostLog(_ request.CTX) (*model.FileData, error) { +func (a *App) GetMattermostLog(ctx request.CTX) (*model.FileData, error) { if !*a.Config().LogSettings.EnableFile { return nil, errors.New("Unable to retrieve mattermost.log because LogSettings: EnableFile is set to false") } diff --git a/server/channels/app/support_packet_test.go b/server/channels/app/support_packet_test.go index f3c534d58e..d92c19e608 100644 --- a/server/channels/app/support_packet_test.go +++ b/server/channels/app/support_packet_test.go @@ -347,7 +347,7 @@ func TestGetMattermostLog(t *testing.T) { *cfg.LogSettings.EnableFile = false }) - fileData, err := th.App.getMattermostLog(th.Context) + fileData, err := th.App.GetMattermostLog(th.Context) assert.Nil(t, fileData) assert.ErrorContains(t, err, "Unable to retrieve mattermost.log because LogSettings: EnableFile is set to false") @@ -367,7 +367,7 @@ func TestGetMattermostLog(t *testing.T) { logLocation := config.GetLogFileLocation(dir) // There is no mattermost.log file yet, so this fails - fileData, err = th.App.getMattermostLog(th.Context) + fileData, err = th.App.GetMattermostLog(th.Context) assert.Nil(t, fileData) assert.ErrorContains(t, err, "failed read mattermost log file at path "+logLocation) @@ -376,7 +376,7 @@ func TestGetMattermostLog(t *testing.T) { err = os.WriteFile(logLocation, d1, 0777) require.NoError(t, err) - fileData, err = th.App.getMattermostLog(th.Context) + fileData, err = th.App.GetMattermostLog(th.Context) require.NoError(t, err) require.NotNil(t, fileData) assert.Equal(t, "mattermost.log", fileData.Filename) diff --git a/server/i18n/en.json b/server/i18n/en.json index 3b25194742..71f414e7b7 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2938,6 +2938,10 @@ "id": "api.system.id_loaded.not_available.app_error", "translation": "ID Loaded Push Notifications are not configured or supported on this server." }, + { + "id": "api.system.logs.download_bytes_buffer.app_error", + "translation": "Failed to write logs to buffer" + }, { "id": "api.system.logs.invalidFilter", "translation": "Invalid log filter" diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 45bfae3c3c..e1e5f8a978 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -5862,6 +5862,20 @@ func (c *Client4) GetLogs(ctx context.Context, page, perPage int) ([]string, *Re return c.ArrayFromJSON(r.Body), BuildResponse(r), nil } +// Download logs as mattermost.log file +func (c *Client4) DownloadLogs(ctx context.Context) ([]byte, *Response, error) { + r, err := c.DoAPIGet(ctx, "/logs/download", "") + if err != nil { + return nil, BuildResponse(r), err + } + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, BuildResponse(r), NewAppError("DownloadLogs", "model.client.read_file.app_error", nil, "", r.StatusCode).Wrap(err) + } + + return data, BuildResponse(r), nil +} + // PostLog is a convenience Web Service call so clients can log messages into // the server-side logs. For example we typically log javascript error messages // into the server-side. It returns the log message if the logging was successful. diff --git a/webapp/channels/src/components/admin_console/server_logs/logs.tsx b/webapp/channels/src/components/admin_console/server_logs/logs.tsx index 789e873e46..d3d96b7381 100644 --- a/webapp/channels/src/components/admin_console/server_logs/logs.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/logs.tsx @@ -12,6 +12,9 @@ import type { LogServerNames, } from '@mattermost/types/admin'; +import {Client4} from 'mattermost-redux/client'; + +import ExternalLink from 'components/external_link'; import AdminHeader from 'components/widgets/admin_console/admin_header'; import LogList from './log_list'; @@ -144,16 +147,28 @@ export default class Logs extends React.PureComponent { - +
+ + + + +
{ - +
+ + + + +
Users and paste the ID into the search filter.", "admin.logs.caller": "Caller", "admin.logs.Debug": "Debug", + "admin.logs.DownloadLogs": "Download Logs", "admin.logs.Error": "Error", "admin.logs.fullEvent": "Full log event", "admin.logs.Info": "Info", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts index 2ca42aecdb..974e39778c 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts @@ -20,6 +20,7 @@ export default keyMirror({ DISABLE_PLUGIN_REQUEST: null, RECEIVED_LOGS: null, + RECEIVED_ALL_PLAIN_LOGS: null, RECEIVED_PLAIN_LOGS: null, RECEIVED_AUDITS: null, RECEIVED_CONFIG: null, diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts index 9fcc116adf..70ddfef904 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts @@ -654,7 +654,7 @@ export default combineReducers({ // array of LogObjects each representing a log entry (JSON) logs, - // array of strings each representing a log entry (legacy) + // array of strings each representing a log entry (legacy) with pagination plainLogs, // object where every key is an audit id and has an object with audit details diff --git a/webapp/channels/src/sass/routes/_admin-console.scss b/webapp/channels/src/sass/routes/_admin-console.scss index 297e21a08b..7b6b0479cc 100644 --- a/webapp/channels/src/sass/routes/_admin-console.scss +++ b/webapp/channels/src/sass/routes/_admin-console.scss @@ -580,6 +580,7 @@ .logs-banner { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; }