diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index d0e7d71eca..335aa08c36 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -1125,6 +1125,25 @@ ##### License Requires either a E10 or E20 license. operationId: GenerateSupportPacket + parameters: + - name: basic_server_logs + in: query + description: | + Specifies whether the server should include or exclude log files. Default value is true. + + __Minimum server version__: 9.8.0 + required: false + schema: + type: boolean + - name: plugin_packets + in: query + description: | + Specifies plugin identifiers whose content should be included in the support packet. + + __Minimum server version__: 9.8.0 + required: false + schema: + type: string responses: "400": $ref: "#/components/responses/BadRequest" diff --git a/server/channels/api4/system.go b/server/channels/api4/system.go index 6396d1963a..860086a86c 100644 --- a/server/channels/api4/system.go +++ b/server/channels/api4/system.go @@ -89,15 +89,28 @@ func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { return } + // We support the existing API hence the logs are always included + // if nothing specified. + includeLogs := true + if r.FormValue("basic_server_logs") == "false" { + includeLogs = false + } + supportPacketOptions := &model.SupportPacketOptions{ + IncludeLogs: includeLogs, + PluginPackets: r.Form["plugin_packets"], + } + // Checking to see if the server has a e10 or e20 license (this feature is only permitted for servers with licenses) if c.App.Channels().License() == nil { c.Err = model.NewAppError("Api4.generateSupportPacket", "api.no_license", nil, "", http.StatusForbidden) return } - fileDatas := c.App.GenerateSupportPacket(c.AppContext) + fileDatas := c.App.GenerateSupportPacket(c.AppContext, supportPacketOptions) // Constructing the ZIP file name as per spec (mattermost_support_packet_YYYY-MM-DD-HH-MM.zip) + // Note that this filename is also being checked at the webapp, please update the + // regex within the commercial_support_modal.tsx file if the naming convention ever changes. now := time.Now() outputZipFilename := fmt.Sprintf("mattermost_support_packet_%s.zip", now.Format("2006-01-02-03-04")) diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index edd7252027..45352fdff7 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -618,7 +618,7 @@ type AppIface interface { GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePresignURLForExport(name string) (*model.PresignURLResponse, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string - GenerateSupportPacket(c request.CTX) []model.FileData + GenerateSupportPacket(c request.CTX, options *model.SupportPacketOptions) []model.FileData GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 9cf5e7a149..f7dab9b31f 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -4786,7 +4786,7 @@ func (a *OpenTracingAppLayer) GeneratePublicLink(siteURL string, info *model.Fil return resultVar0 } -func (a *OpenTracingAppLayer) GenerateSupportPacket(c request.CTX) []model.FileData { +func (a *OpenTracingAppLayer) GenerateSupportPacket(c request.CTX, options *model.SupportPacketOptions) []model.FileData { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GenerateSupportPacket") @@ -4798,7 +4798,7 @@ func (a *OpenTracingAppLayer) GenerateSupportPacket(c request.CTX) []model.FileD }() defer span.Finish() - resultVar0 := a.app.GenerateSupportPacket(c) + resultVar0 := a.app.GenerateSupportPacket(c, options) return resultVar0 } diff --git a/server/channels/app/support_packet.go b/server/channels/app/support_packet.go index dcc8dbab22..1e15d49819 100644 --- a/server/channels/app/support_packet.go +++ b/server/channels/app/support_packet.go @@ -26,7 +26,7 @@ const ( cpuProfileDuration = 5 * time.Second ) -func (a *App) GenerateSupportPacket(c request.CTX) []model.FileData { +func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketOptions) []model.FileData { // If any errors we come across within this function, we will log it in a warning.txt file so that we know why certain files did not get produced if any var warnings []string @@ -35,14 +35,17 @@ func (a *App) GenerateSupportPacket(c request.CTX) []model.FileData { // A array of the functions that we can iterate through since they all have the same return value functions := map[string]func(c request.CTX) (*model.FileData, error){ - "support package": a.generateSupportPacketYaml, - "plugins": a.createPluginsFile, - "config": a.createSanitizedConfigFile, - "mattermost log": a.getMattermostLog, - "notification log": a.getNotificationsLog, - "cpu profile": a.createCPUProfile, - "heap profile": a.createHeapProfile, - "goroutines": a.createGoroutineProfile, + "support package": a.generateSupportPacketYaml, + "plugins": a.createPluginsFile, + "config": a.createSanitizedConfigFile, + "cpu profile": a.createCPUProfile, + "heap profile": a.createHeapProfile, + "goroutines": a.createGoroutineProfile, + } + + if options.IncludeLogs { + functions["mattermost log"] = a.getMattermostLog + functions["notification log"] = a.getNotificationsLog } for name, fn := range functions { @@ -57,6 +60,27 @@ func (a *App) GenerateSupportPacket(c request.CTX) []model.FileData { } } + if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { + pluginContext := pluginContext(c) + for _, id := range options.PluginPackets { + hooks, err := pluginsEnvironment.HooksForPlugin(id) + if err != nil { + c.Logger().Error("Failed to call hooks for plugin", mlog.Err(err), mlog.String("plugin", id)) + warnings = append(warnings, err.Error()) + continue + } + pluginData, err := hooks.GenerateSupportData(pluginContext) + if err != nil { + c.Logger().Warn("Failed to generate plugin file for support package", mlog.Err(err), mlog.String("plugin", id)) + warnings = append(warnings, err.Error()) + continue + } + for _, data := range pluginData { + fileDatas = append(fileDatas, *data) + } + } + } + // Adding a warning.txt file to the fileDatas if any warning if len(warnings) > 0 { finalWarning := strings.Join(warnings, "\n") diff --git a/server/channels/app/support_packet_test.go b/server/channels/app/support_packet_test.go index 97fe7b173d..f3c534d58e 100644 --- a/server/channels/app/support_packet_test.go +++ b/server/channels/app/support_packet_test.go @@ -168,55 +168,91 @@ func TestGenerateSupportPacket(t *testing.T) { logLocation := config.GetLogFileLocation(dir) notificationsLogLocation := config.GetNotificationsLogFileLocation(dir) - d1 := []byte("hello\ngo\n") - err = os.WriteFile(logLocation, d1, 0777) - require.NoError(t, err) - err = os.WriteFile(notificationsLogLocation, d1, 0777) - require.NoError(t, err) - - fileDatas := th.App.GenerateSupportPacket(th.Context) - var rFileNames []string - testFiles := []string{ - "support_packet.yaml", - "plugins.json", - "sanitized_config.json", - "mattermost.log", - "notifications.log", - "cpu.prof", - "heap.prof", - "goroutines", + genMockLogFiles := func() { + d1 := []byte("hello\ngo\n") + genErr := os.WriteFile(logLocation, d1, 0777) + require.NoError(t, genErr) + genErr = os.WriteFile(notificationsLogLocation, d1, 0777) + require.NoError(t, genErr) } - for _, fileData := range fileDatas { - require.NotNil(t, fileData) - assert.Positive(t, len(fileData.Body)) + genMockLogFiles() - rFileNames = append(rFileNames, fileData.Filename) - } - assert.ElementsMatch(t, testFiles, rFileNames) + t.Run("generate support packet with logs", func(t *testing.T) { + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: true, + }) + var rFileNames []string + testFiles := []string{ + "support_packet.yaml", + "plugins.json", + "sanitized_config.json", + "mattermost.log", + "notifications.log", + "cpu.prof", + "heap.prof", + "goroutines", + } + for _, fileData := range fileDatas { + require.NotNil(t, fileData) + assert.Positive(t, len(fileData.Body)) - // Remove these two files and ensure that warning.txt file is generated - err = os.Remove(logLocation) - require.NoError(t, err) - err = os.Remove(notificationsLogLocation) - require.NoError(t, err) - fileDatas = th.App.GenerateSupportPacket(th.Context) - testFiles = []string{ - "support_packet.yaml", - "plugins.json", - "sanitized_config.json", - "cpu.prof", - "heap.prof", - "warning.txt", - "goroutines", - } - rFileNames = nil - for _, fileData := range fileDatas { - require.NotNil(t, fileData) - assert.Positive(t, len(fileData.Body)) + rFileNames = append(rFileNames, fileData.Filename) + } + assert.ElementsMatch(t, testFiles, rFileNames) + }) - rFileNames = append(rFileNames, fileData.Filename) - } - assert.ElementsMatch(t, testFiles, rFileNames) + t.Run("generate support packet without logs", func(t *testing.T) { + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + }) + + testFiles := []string{ + "support_packet.yaml", + "plugins.json", + "sanitized_config.json", + "cpu.prof", + "heap.prof", + "goroutines", + } + var rFileNames []string + for _, fileData := range fileDatas { + require.NotNil(t, fileData) + assert.Positive(t, len(fileData.Body)) + + rFileNames = append(rFileNames, fileData.Filename) + } + assert.ElementsMatch(t, testFiles, rFileNames) + }) + + t.Run("remove the log files and ensure that warning.txt file is generated", func(t *testing.T) { + // Remove these two files and ensure that warning.txt file is generated + err = os.Remove(logLocation) + require.NoError(t, err) + err = os.Remove(notificationsLogLocation) + require.NoError(t, err) + t.Cleanup(genMockLogFiles) + + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: true, + }) + testFiles := []string{ + "support_packet.yaml", + "plugins.json", + "sanitized_config.json", + "cpu.prof", + "heap.prof", + "warning.txt", + "goroutines", + } + var rFileNames []string + for _, fileData := range fileDatas { + require.NotNil(t, fileData) + assert.Positive(t, len(fileData.Body)) + + rFileNames = append(rFileNames, fileData.Filename) + } + assert.ElementsMatch(t, testFiles, rFileNames) + }) t.Run("steps that generated an error should still return file data", func(t *testing.T) { mockStore := smocks.Store{} @@ -241,7 +277,9 @@ func TestGenerateSupportPacket(t *testing.T) { mockStore.On("GetDbVersion", false).Return("1.0.0", nil) th.App.Srv().SetStore(&mockStore) - fileDatas := th.App.GenerateSupportPacket(th.Context) + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + }) var rFileNames []string for _, fileData := range fileDatas { diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go new file mode 100644 index 0000000000..8ec8e523d9 --- /dev/null +++ b/server/public/model/support_packet.go @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type SupportPacket struct { + /* Build information */ + + ServerOS string `yaml:"server_os"` + ServerArchitecture string `yaml:"server_architecture"` + ServerVersion string `yaml:"server_version"` + BuildHash string `yaml:"build_hash"` + + /* DB */ + + DatabaseType string `yaml:"database_type"` + DatabaseVersion string `yaml:"database_version"` + DatabaseSchemaVersion string `yaml:"database_schema_version"` + WebsocketConnections int `yaml:"websocket_connections"` + MasterDbConnections int `yaml:"master_db_connections"` + ReplicaDbConnections int `yaml:"read_db_connections"` + + /* Cluster */ + + ClusterID string `yaml:"cluster_id"` + + /* File store */ + + FileDriver string `yaml:"file_driver"` + FileStatus string `yaml:"file_status"` + + /* LDAP */ + + LdapVendorName string `yaml:"ldap_vendor_name,omitempty"` + LdapVendorVersion string `yaml:"ldap_vendor_version,omitempty"` + + /* Elastic Search */ + + ElasticServerVersion string `yaml:"elastic_server_version,omitempty"` + ElasticServerPlugins []string `yaml:"elastic_server_plugins,omitempty"` + + /* License */ + + LicenseTo string `yaml:"license_to"` + LicenseSupportedUsers int `yaml:"license_supported_users"` + LicenseIsTrial bool `yaml:"license_is_trial,omitempty"` + + /* Server stats */ + + ActiveUsers int `yaml:"active_users"` + DailyActiveUsers int `yaml:"daily_active_users"` + MonthlyActiveUsers int `yaml:"monthly_active_users"` + InactiveUserCount int `yaml:"inactive_user_count"` + TotalPosts int `yaml:"total_posts"` + TotalChannels int `yaml:"total_channels"` + TotalTeams int `yaml:"total_teams"` + + /* Jobs */ + + DataRetentionJobs []*Job `yaml:"data_retention_jobs"` + MessageExportJobs []*Job `yaml:"message_export_jobs"` + ElasticPostIndexingJobs []*Job `yaml:"elastic_post_indexing_jobs"` + ElasticPostAggregationJobs []*Job `yaml:"elastic_post_aggregation_jobs"` + BlevePostIndexingJobs []*Job `yaml:"bleve_post_indexin_jobs"` + LdapSyncJobs []*Job `yaml:"ldap_sync_jobs"` + MigrationJobs []*Job `yaml:"migration_jobs"` +} + +type FileData struct { + Filename string + Body []byte +} + +type SupportPacketOptions struct { + IncludeLogs bool `json:"include_logs"` // IncludeLogs is the option to include server logs + PluginPackets []string `json:"plugin_packets"` // PluginPackets is a list of pluginids to call hooks +} + +// SupportPacketOptionsFromReader decodes a json-encoded request from the given io.Reader. +func SupportPacketOptionsFromReader(reader io.Reader) (*SupportPacketOptions, error) { + var r *SupportPacketOptions + err := json.NewDecoder(reader).Decode(&r) + if err != nil { + return nil, err + } + + return r, nil +} diff --git a/server/public/model/system.go b/server/public/model/system.go index ecf3ff0017..bea20ed938 100644 --- a/server/public/model/system.go +++ b/server/public/model/system.go @@ -78,73 +78,6 @@ type ServerBusyState struct { ExpiresTS string `json:"expires_ts,omitempty"` } -type SupportPacket struct { - /* Build information */ - - ServerOS string `yaml:"server_os"` - ServerArchitecture string `yaml:"server_architecture"` - ServerVersion string `yaml:"server_version"` - BuildHash string `yaml:"build_hash"` - - /* DB */ - - DatabaseType string `yaml:"database_type"` - DatabaseVersion string `yaml:"database_version"` - DatabaseSchemaVersion string `yaml:"database_schema_version"` - WebsocketConnections int `yaml:"websocket_connections"` - MasterDbConnections int `yaml:"master_db_connections"` - ReplicaDbConnections int `yaml:"read_db_connections"` - - /* Cluster */ - - ClusterID string `yaml:"cluster_id"` - - /* File store */ - - FileDriver string `yaml:"file_driver"` - FileStatus string `yaml:"file_status"` - - /* LDAP */ - - LdapVendorName string `yaml:"ldap_vendor_name,omitempty"` - LdapVendorVersion string `yaml:"ldap_vendor_version,omitempty"` - - /* Elastic Search */ - - ElasticServerVersion string `yaml:"elastic_server_version,omitempty"` - ElasticServerPlugins []string `yaml:"elastic_server_plugins,omitempty"` - - /* License */ - - LicenseTo string `yaml:"license_to"` - LicenseSupportedUsers int `yaml:"license_supported_users"` - LicenseIsTrial bool `yaml:"license_is_trial,omitempty"` - - /* Server stats */ - - ActiveUsers int `yaml:"active_users"` - DailyActiveUsers int `yaml:"daily_active_users"` - MonthlyActiveUsers int `yaml:"monthly_active_users"` - InactiveUserCount int `yaml:"inactive_user_count"` - TotalPosts int `yaml:"total_posts"` - TotalChannels int `yaml:"total_channels"` - TotalTeams int `yaml:"total_teams"` - - /* Jobs */ - - DataRetentionJobs []*Job `yaml:"data_retention_jobs"` - MessageExportJobs []*Job `yaml:"message_export_jobs"` - ElasticPostIndexingJobs []*Job `yaml:"elastic_post_indexing_jobs"` - ElasticPostAggregationJobs []*Job `yaml:"elastic_post_aggregation_jobs"` - BlevePostIndexingJobs []*Job `yaml:"bleve_post_indexin_jobs"` - LdapSyncJobs []*Job `yaml:"ldap_sync_jobs"` - MigrationJobs []*Job `yaml:"migration_jobs"` -} - -type FileData struct { - Filename string - Body []byte -} type AppliedMigration struct { Version int `json:"version"` Name string `json:"name"` diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index b5f84a2181..ea1905fa76 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -1125,6 +1125,42 @@ func (s *hooksRPCServer) OnSharedChannelsProfileImageSyncMsg(args *Z_OnSharedCha return nil } +func init() { + hookNameToId["GenerateSupportData"] = GenerateSupportDataID +} + +type Z_GenerateSupportDataArgs struct { + A *Context +} + +type Z_GenerateSupportDataReturns struct { + A []*model.FileData + B error +} + +func (g *hooksRPCClient) GenerateSupportData(c *Context) ([]*model.FileData, error) { + _args := &Z_GenerateSupportDataArgs{c} + _returns := &Z_GenerateSupportDataReturns{} + if g.implemented[GenerateSupportDataID] { + if err := g.client.Call("Plugin.GenerateSupportData", _args, _returns); err != nil { + g.log.Error("RPC call GenerateSupportData to plugin failed.", mlog.Err(err)) + } + } + return _returns.A, _returns.B +} + +func (s *hooksRPCServer) GenerateSupportData(args *Z_GenerateSupportDataArgs, returns *Z_GenerateSupportDataReturns) error { + if hook, ok := s.impl.(interface { + GenerateSupportData(c *Context) ([]*model.FileData, error) + }); ok { + returns.A, returns.B = hook.GenerateSupportData(args.A) + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("Hook GenerateSupportData called but not implemented.")) + } + return nil +} + type Z_RegisterCommandArgs struct { A *model.Command } diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index b1dbf032ad..a8b33fe97a 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -60,6 +60,7 @@ const ( PreferencesHaveChangedID = 42 OnSharedChannelsAttachmentSyncMsgID = 43 OnSharedChannelsProfileImageSyncMsgID = 44 + GenerateSupportDataID = 45 TotalHooksID = iota ) @@ -382,4 +383,10 @@ type Hooks interface { // // Minimum server version: 9.5 OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error + + // GenerateSupportData is invoked when a Support Packet gets generated. + // It allows plugins to include their own content in the Support Packet. + // + // Minimum server version: 9.8 + GenerateSupportData(c *Context) ([]*model.FileData, error) } diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index 531be80321..130a8c0545 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -284,3 +284,10 @@ func (hooks *hooksTimerLayer) OnSharedChannelsProfileImageSyncMsg(user *model.Us hooks.recordTime(startTime, "OnSharedChannelsProfileImageSyncMsg", _returnsA == nil) return _returnsA } + +func (hooks *hooksTimerLayer) GenerateSupportData(c *Context) ([]*model.FileData, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := hooks.hooksImpl.GenerateSupportData(c) + hooks.recordTime(startTime, "GenerateSupportData", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index c61eae7892..3846798913 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -105,6 +105,32 @@ func (_m *Hooks) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, fil return r0, r1 } +// GenerateSupportData provides a mock function with given fields: c +func (_m *Hooks) GenerateSupportData(c *plugin.Context) ([]*model.FileData, error) { + ret := _m.Called(c) + + var r0 []*model.FileData + var r1 error + if rf, ok := ret.Get(0).(func(*plugin.Context) ([]*model.FileData, error)); ok { + return rf(c) + } + if rf, ok := ret.Get(0).(func(*plugin.Context) []*model.FileData); ok { + r0 = rf(c) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.FileData) + } + } + + if rf, ok := ret.Get(1).(func(*plugin.Context) error); ok { + r1 = rf(c) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Implemented provides a mock function with given fields: func (_m *Hooks) Implemented() ([]string, error) { ret := _m.Called() diff --git a/webapp/channels/src/components/commercial_support_modal/__snapshots__/commercial_support_modal.test.tsx.snap b/webapp/channels/src/components/commercial_support_modal/__snapshots__/commercial_support_modal.test.tsx.snap index 7fe67ed899..e50a9f88ef 100644 --- a/webapp/channels/src/components/commercial_support_modal/__snapshots__/commercial_support_modal.test.tsx.snap +++ b/webapp/channels/src/components/commercial_support_modal/__snapshots__/commercial_support_modal.test.tsx.snap @@ -52,11 +52,7 @@ exports[`components/CommercialSupportModal should match snapshot 1`] = ` className="CommercialSupportModal" > - - - } mode="info" onDismiss={[Function]} /> +
+ +
+
+ + + + +
diff --git a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.scss b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.scss index 239612547c..e016bff93e 100644 --- a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.scss +++ b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.scss @@ -1,5 +1,5 @@ .CommercialSupportModal { - padding: 20px 20px; + padding: 0 32px 32px 32px; .DownloadSupportPacket { margin-top: 20px; @@ -8,4 +8,19 @@ .AlertBanner { margin-top: 20px; } + + &__packet_contents_download { + margin-top: 20px; + margin-bottom: 12px; + } + + &__options_checkbox_label { + padding-left: 12px; + font-weight: 500; + } +} + +.modal-header .modal-title { + font-family: Metropolis, sans-serif; + font-weight: 600; } diff --git a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.test.tsx b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.test.tsx index 0126c12d88..2954d0d2c2 100644 --- a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.test.tsx +++ b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.test.tsx @@ -17,6 +17,7 @@ describe('components/CommercialSupportModal', () => { showBannerWarning={true} isCloud={false} currentUser={mockUser} + packetContents={[]} />, ); expect(wrapper).toMatchSnapshot(); diff --git a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx index 0b477c6e90..cbace25363 100644 --- a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx +++ b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx @@ -1,16 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import moment from 'moment'; import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import type {SupportPacketContent} from '@mattermost/types/admin'; import type {UserProfile} from '@mattermost/types/users'; import {Client4} from 'mattermost-redux/client'; import AlertBanner from 'components/alert_banner'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner'; import './commercial_support_modal.scss'; @@ -26,20 +29,25 @@ type Props = { isCloud: boolean; currentUser: UserProfile; + + packetContents: SupportPacketContent[]; }; type State = { show: boolean; showBannerWarning: boolean; + packetContents: SupportPacketContent[]; + loading: boolean; }; export default class CommercialSupportModal extends React.PureComponent { constructor(props: Props) { super(props); - this.state = { show: true, showBannerWarning: props.showBannerWarning, + packetContents: props.packetContents, + loading: false, }; } @@ -61,6 +69,59 @@ export default class CommercialSupportModal extends React.PureComponent { + this.setState({ + packetContents: this.state.packetContents.map((content, currentIndex) => ( + (currentIndex === index && !content.mandatory) ? {...content, selected: !content.selected} : content + )), + }); + }; + + genereateDownloadURLWithParams = (): string => { + const url = new URL(Client4.getSystemRoute() + '/support_packet'); + this.state.packetContents.forEach((content) => { + if (content.id === 'basic.server.logs') { + url.searchParams.set('basic_server_logs', String(content.selected)); + } else if (!content.mandatory && content.selected) { + url.searchParams.append('plugin_packets', content.id); + } + }); + return url.toString(); + }; + + extractFilename = (input: string | null): string => { + // construct the expected filename in case of an error in the header + const formattedDate = (moment(new Date())).format('YYYY-MM-DD-HH-mm'); + const presumedFileName = `mattermost_support_packet_${formattedDate}.zip`; + + if (input === null) { + return presumedFileName; + } + + const regex = /filename\*?=["']?((?:\\.|[^"'\s])+)(?=["']?)/g; + const matches = regex.exec(input!); + + return matches ? matches[1] : presumedFileName; + }; + + downloadSupportPacket = async () => { + this.setState({loading: true}); + const res = await fetch(this.genereateDownloadURLWithParams(), { + method: 'GET', + headers: {'Content-Type': 'application/zip'}, + }); + const blob = await res.blob(); + this.setState({loading: false}); + + const href = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = href; + link.setAttribute('download', this.extractFilename(res.headers.get('content-disposition'))); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + render() { const {showBannerWarning} = this.state; const {isCloud, currentUser} = this.props; @@ -86,33 +147,71 @@ export default class CommercialSupportModal extends React.PureComponent - - - {showBannerWarning && } onDismiss={this.hideBannerWarning} /> } +
+ +
+ {this.state.packetContents.map((item, index) => ( +
+ this.updateCheckStatus(index)} + /> + + {(text) => ( + ) + } + +
+ ))} + diff --git a/webapp/channels/src/components/commercial_support_modal/index.ts b/webapp/channels/src/components/commercial_support_modal/index.ts index a7070145ae..9df73c99cc 100644 --- a/webapp/channels/src/components/commercial_support_modal/index.ts +++ b/webapp/channels/src/components/commercial_support_modal/index.ts @@ -16,11 +16,27 @@ function mapStateToProps(state: GlobalState) { const isCloud = license.Cloud === 'true'; const currentUser = getCurrentUser(state); const showBannerWarning = (config.EnableFile !== 'true' || config.FileLevel !== 'DEBUG') && !(isCloud); + const packetContents = [ + {id: 'basic.contents', label: 'Basic contents', selected: true, mandatory: true}, + {id: 'basic.server.logs', label: 'Server logs', selected: true, mandatory: false}, + ]; + + for (const [key, value] of Object.entries(state.entities.admin.plugins!)) { + if (value.active && value.props !== undefined && value.props.support_packet !== undefined) { + packetContents.push({ + id: key, + label: value.props.support_packet, + selected: false, + mandatory: false, + }); + } + } return { isCloud, currentUser, showBannerWarning, + packetContents, }; } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b467906c87..ea7ba00052 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3345,10 +3345,11 @@ "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", "combined_system_message.you": "You", - "commercial_support.description": "If you're experiencing issues, [submit a support ticket.](!{supportLink})\n \n**Download Support Packet**\n \nWe recommend that you download additional environment details about your Mattermost environment to help with troubleshooting. Once downloaded, attach the packet to your support ticket to share with our Customer Support team.", + "commercial_support.description": "If you're experiencing issues, [submit a support ticket](!{supportLink}). To help with troubleshooting, it's recommended to download the support packet below that includes more details about your Mattermost environment.", + "commercial_support.download_contents": "**Select your Support Packet contents to download**", "commercial_support.download_support_packet": "Download Support Packet", "commercial_support.title": "Commercial Support", - "commercial_support.warning.banner": "Before downloading the support packet, set **Output Logs to File** to **true** and set **File Log Level** to **DEBUG** [here](!/admin_console/environment/logging).", + "commercial_support.warning.banner": "Before downloading the Support Packet, set **Output Logs to File** to **true** and set **File Log Level** to **DEBUG** [here](!/admin_console/environment/logging).", "confirm_modal.cancel": "Cancel", "confirm_switch_to_yearly_modal.confirm": "Confirm", "confirm_switch_to_yearly_modal.contact_sales": "Contact Sales", diff --git a/webapp/platform/types/src/admin.ts b/webapp/platform/types/src/admin.ts index 9a5af054b7..2e2df211ac 100644 --- a/webapp/platform/types/src/admin.ts +++ b/webapp/platform/types/src/admin.ts @@ -125,3 +125,11 @@ export type SchemaMigration = { version: number; name: string; }; + +export type SupportPacketContent = { + id: string; + translation_id?: string; + label: string; + selected: boolean; + mandatory: boolean; +}