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 <harrisonmhealey@gmail.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
71c25fb316
Коммит
a8b18ac807
@@ -53,6 +53,7 @@ build-v4: node_modules playbooks
|
|||||||
@cat $(V4_SRC)/bookmarks.yaml >> $(V4_YAML)
|
@cat $(V4_SRC)/bookmarks.yaml >> $(V4_YAML)
|
||||||
@cat $(V4_SRC)/reports.yaml >> $(V4_YAML)
|
@cat $(V4_SRC)/reports.yaml >> $(V4_YAML)
|
||||||
@cat $(V4_SRC)/limits.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)
|
@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)/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
|
@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
|
||||||
|
|||||||
18
api/v4/source/logs.yaml
Обычный файл
18
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"
|
||||||
@@ -50,6 +50,7 @@ func (api *API) InitSystem() {
|
|||||||
api.BaseRoutes.APIRoot.Handle("/caches/invalidate", api.APISessionRequired(invalidateCaches)).Methods("POST")
|
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", 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/query", api.APISessionRequired(queryLogs)).Methods("POST")
|
||||||
api.BaseRoutes.APIRoot.Handle("/logs", api.APIHandler(postLog)).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)))
|
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) {
|
func postLog(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
forceToDebug := false
|
forceToDebug := false
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,46 @@ func TestGetLogs(t *testing.T) {
|
|||||||
CheckUnauthorizedStatus(t, resp)
|
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) {
|
func TestPostLog(t *testing.T) {
|
||||||
th := Setup(t)
|
th := Setup(t)
|
||||||
defer th.TearDown()
|
defer th.TearDown()
|
||||||
|
|||||||
@@ -728,6 +728,7 @@ type AppIface interface {
|
|||||||
GetLatestVersion(rctx request.CTX, latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError)
|
GetLatestVersion(rctx request.CTX, latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError)
|
||||||
GetLogs(rctx request.CTX, page, perPage int) ([]string, *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)
|
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)
|
GetMemberCountsByGroup(rctx request.CTX, channelID string, includeTimezones bool) ([]*model.ChannelMemberCountByGroup, *model.AppError)
|
||||||
GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string
|
GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string
|
||||||
GetMultipleEmojiByName(c request.CTX, names []string) ([]*model.Emoji, *model.AppError)
|
GetMultipleEmojiByName(c request.CTX, names []string) ([]*model.Emoji, *model.AppError)
|
||||||
|
|||||||
@@ -7616,6 +7616,28 @@ func (a *OpenTracingAppLayer) GetMarketplacePlugins(rctx request.CTX, filter *mo
|
|||||||
return resultVar0, resultVar1
|
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) {
|
func (a *OpenTracingAppLayer) GetMemberCountsByGroup(rctx request.CTX, channelID string, includeTimezones bool) ([]*model.ChannelMemberCountByGroup, *model.AppError) {
|
||||||
origCtx := a.ctx
|
origCtx := a.ctx
|
||||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetMemberCountsByGroup")
|
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetMemberCountsByGroup")
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO
|
|||||||
}
|
}
|
||||||
|
|
||||||
if options.IncludeLogs {
|
if options.IncludeLogs {
|
||||||
functions["mattermost log"] = a.getMattermostLog
|
functions["mattermost log"] = a.GetMattermostLog
|
||||||
functions["notification log"] = a.getNotificationsLog
|
functions["notification log"] = a.getNotificationsLog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ func (a *App) getNotificationsLog(_ request.CTX) (*model.FileData, error) {
|
|||||||
return fileData, nil
|
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 {
|
if !*a.Config().LogSettings.EnableFile {
|
||||||
return nil, errors.New("Unable to retrieve mattermost.log because LogSettings: EnableFile is set to false")
|
return nil, errors.New("Unable to retrieve mattermost.log because LogSettings: EnableFile is set to false")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ func TestGetMattermostLog(t *testing.T) {
|
|||||||
*cfg.LogSettings.EnableFile = false
|
*cfg.LogSettings.EnableFile = false
|
||||||
})
|
})
|
||||||
|
|
||||||
fileData, err := th.App.getMattermostLog(th.Context)
|
fileData, err := th.App.GetMattermostLog(th.Context)
|
||||||
assert.Nil(t, fileData)
|
assert.Nil(t, fileData)
|
||||||
assert.ErrorContains(t, err, "Unable to retrieve mattermost.log because LogSettings: EnableFile is set to false")
|
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)
|
logLocation := config.GetLogFileLocation(dir)
|
||||||
|
|
||||||
// There is no mattermost.log file yet, so this fails
|
// 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.Nil(t, fileData)
|
||||||
assert.ErrorContains(t, err, "failed read mattermost log file at path "+logLocation)
|
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)
|
err = os.WriteFile(logLocation, d1, 0777)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fileData, err = th.App.getMattermostLog(th.Context)
|
fileData, err = th.App.GetMattermostLog(th.Context)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, fileData)
|
require.NotNil(t, fileData)
|
||||||
assert.Equal(t, "mattermost.log", fileData.Filename)
|
assert.Equal(t, "mattermost.log", fileData.Filename)
|
||||||
|
|||||||
@@ -2938,6 +2938,10 @@
|
|||||||
"id": "api.system.id_loaded.not_available.app_error",
|
"id": "api.system.id_loaded.not_available.app_error",
|
||||||
"translation": "ID Loaded Push Notifications are not configured or supported on this server."
|
"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",
|
"id": "api.system.logs.invalidFilter",
|
||||||
"translation": "Invalid log filter"
|
"translation": "Invalid log filter"
|
||||||
|
|||||||
@@ -5862,6 +5862,20 @@ func (c *Client4) GetLogs(ctx context.Context, page, perPage int) ([]string, *Re
|
|||||||
return c.ArrayFromJSON(r.Body), BuildResponse(r), nil
|
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
|
// 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
|
// 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.
|
// into the server-side. It returns the log message if the logging was successful.
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import type {
|
|||||||
LogServerNames,
|
LogServerNames,
|
||||||
} from '@mattermost/types/admin';
|
} 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 AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||||
|
|
||||||
import LogList from './log_list';
|
import LogList from './log_list';
|
||||||
@@ -144,6 +147,7 @@ export default class Logs extends React.PureComponent<Props, State> {
|
|||||||
<FormattedMessage {...messages.bannerDesc}/>
|
<FormattedMessage {...messages.bannerDesc}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='banner-buttons'>
|
||||||
<button
|
<button
|
||||||
type='submit'
|
type='submit'
|
||||||
className='btn btn-primary'
|
className='btn btn-primary'
|
||||||
@@ -154,6 +158,17 @@ export default class Logs extends React.PureComponent<Props, State> {
|
|||||||
defaultMessage='Reload Logs'
|
defaultMessage='Reload Logs'
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<ExternalLink
|
||||||
|
location='download_logs'
|
||||||
|
className='btn btn-primary'
|
||||||
|
href={Client4.getUrl() + '/api/v4/logs/download'}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.logs.DownloadLogs'
|
||||||
|
defaultMessage='Download Logs'
|
||||||
|
/>
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
<PlainLogList
|
<PlainLogList
|
||||||
logs={this.props.plainLogs}
|
logs={this.props.plainLogs}
|
||||||
nextPage={this.nextPage}
|
nextPage={this.nextPage}
|
||||||
@@ -170,6 +185,7 @@ export default class Logs extends React.PureComponent<Props, State> {
|
|||||||
<FormattedMessage {...messages.bannerDesc}/>
|
<FormattedMessage {...messages.bannerDesc}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='banner-buttons'>
|
||||||
<button
|
<button
|
||||||
type='submit'
|
type='submit'
|
||||||
className='btn btn-primary'
|
className='btn btn-primary'
|
||||||
@@ -180,6 +196,17 @@ export default class Logs extends React.PureComponent<Props, State> {
|
|||||||
defaultMessage='Reload Logs'
|
defaultMessage='Reload Logs'
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<ExternalLink
|
||||||
|
location='download_logs'
|
||||||
|
className='btn btn-primary'
|
||||||
|
href={Client4.getUrl() + '/api/v4/logs/download'}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.logs.DownloadLogs'
|
||||||
|
defaultMessage='Download Logs'
|
||||||
|
/>
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LogList
|
<LogList
|
||||||
loading={this.state.loadingLogs}
|
loading={this.state.loadingLogs}
|
||||||
|
|||||||
@@ -1455,6 +1455,7 @@
|
|||||||
"admin.logs.bannerDesc": "To look up users by User ID or Token ID, go to User Management > Users and paste the ID into the search filter.",
|
"admin.logs.bannerDesc": "To look up users by User ID or Token ID, go to User Management > Users and paste the ID into the search filter.",
|
||||||
"admin.logs.caller": "Caller",
|
"admin.logs.caller": "Caller",
|
||||||
"admin.logs.Debug": "Debug",
|
"admin.logs.Debug": "Debug",
|
||||||
|
"admin.logs.DownloadLogs": "Download Logs",
|
||||||
"admin.logs.Error": "Error",
|
"admin.logs.Error": "Error",
|
||||||
"admin.logs.fullEvent": "Full log event",
|
"admin.logs.fullEvent": "Full log event",
|
||||||
"admin.logs.Info": "Info",
|
"admin.logs.Info": "Info",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default keyMirror({
|
|||||||
DISABLE_PLUGIN_REQUEST: null,
|
DISABLE_PLUGIN_REQUEST: null,
|
||||||
|
|
||||||
RECEIVED_LOGS: null,
|
RECEIVED_LOGS: null,
|
||||||
|
RECEIVED_ALL_PLAIN_LOGS: null,
|
||||||
RECEIVED_PLAIN_LOGS: null,
|
RECEIVED_PLAIN_LOGS: null,
|
||||||
RECEIVED_AUDITS: null,
|
RECEIVED_AUDITS: null,
|
||||||
RECEIVED_CONFIG: null,
|
RECEIVED_CONFIG: null,
|
||||||
|
|||||||
@@ -654,7 +654,7 @@ export default combineReducers({
|
|||||||
// array of LogObjects each representing a log entry (JSON)
|
// array of LogObjects each representing a log entry (JSON)
|
||||||
logs,
|
logs,
|
||||||
|
|
||||||
// array of strings each representing a log entry (legacy)
|
// array of strings each representing a log entry (legacy) with pagination
|
||||||
plainLogs,
|
plainLogs,
|
||||||
|
|
||||||
// object where every key is an audit id and has an object with audit details
|
// object where every key is an audit id and has an object with audit details
|
||||||
|
|||||||
@@ -580,6 +580,7 @@
|
|||||||
|
|
||||||
.logs-banner {
|
.logs-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user