diff options
-rw-r--r-- | client/src/app/+admin/system/logs/logs.component.html | 2 | ||||
-rw-r--r-- | client/src/app/+admin/system/logs/logs.component.scss | 4 | ||||
-rw-r--r-- | client/src/app/+admin/system/logs/logs.component.ts | 33 | ||||
-rw-r--r-- | client/src/app/+admin/system/logs/logs.service.ts | 9 | ||||
-rw-r--r-- | client/src/app/shared/shared-forms/select/select-tags.component.html | 2 | ||||
-rw-r--r-- | client/src/app/shared/shared-forms/select/select-tags.component.ts | 1 | ||||
-rw-r--r-- | server/controllers/api/server/logs.ts | 42 | ||||
-rw-r--r-- | server/middlewares/validators/logs.ts | 7 | ||||
-rw-r--r-- | server/tests/api/server/logs.ts | 23 | ||||
-rw-r--r-- | shared/extra-utils/logs/logs-command.ts | 7 |
10 files changed, 102 insertions, 28 deletions
diff --git a/client/src/app/+admin/system/logs/logs.component.html b/client/src/app/+admin/system/logs/logs.component.html index b2c7f84bc..18011b205 100644 --- a/client/src/app/+admin/system/logs/logs.component.html +++ b/client/src/app/+admin/system/logs/logs.component.html | |||
@@ -28,6 +28,8 @@ | |||
28 | </ng-option> | 28 | </ng-option> |
29 | </ng-select> | 29 | </ng-select> |
30 | 30 | ||
31 | <my-select-tags i18n-placeholder placeholder="Filter logs by tags" [(ngModel)]="tagsOneOf" (ngModelChange)="refresh()"></my-select-tags> | ||
32 | |||
31 | <my-button i18n-label label="Refresh" icon="refresh" (click)="refresh()"></my-button> | 33 | <my-button i18n-label label="Refresh" icon="refresh" (click)="refresh()"></my-button> |
32 | </div> | 34 | </div> |
33 | 35 | ||
diff --git a/client/src/app/+admin/system/logs/logs.component.scss b/client/src/app/+admin/system/logs/logs.component.scss index fefa7efc2..be66d563b 100644 --- a/client/src/app/+admin/system/logs/logs.component.scss +++ b/client/src/app/+admin/system/logs/logs.component.scss | |||
@@ -52,9 +52,7 @@ | |||
52 | @include peertube-select-container(150px); | 52 | @include peertube-select-container(150px); |
53 | } | 53 | } |
54 | 54 | ||
55 | my-button, | 55 | > * { |
56 | .peertube-select-container, | ||
57 | ng-select { | ||
58 | @include margin-left(10px); | 56 | @include margin-left(10px); |
59 | } | 57 | } |
60 | } | 58 | } |
diff --git a/client/src/app/+admin/system/logs/logs.component.ts b/client/src/app/+admin/system/logs/logs.component.ts index 865ab80a2..06237522a 100644 --- a/client/src/app/+admin/system/logs/logs.component.ts +++ b/client/src/app/+admin/system/logs/logs.component.ts | |||
@@ -23,6 +23,7 @@ export class LogsComponent implements OnInit { | |||
23 | startDate: string | 23 | startDate: string |
24 | level: LogLevel | 24 | level: LogLevel |
25 | logType: 'audit' | 'standard' | 25 | logType: 'audit' | 'standard' |
26 | tagsOneOf: string[] = [] | ||
26 | 27 | ||
27 | constructor ( | 28 | constructor ( |
28 | private logsService: LogsService, | 29 | private logsService: LogsService, |
@@ -51,20 +52,28 @@ export class LogsComponent implements OnInit { | |||
51 | load () { | 52 | load () { |
52 | this.loading = true | 53 | this.loading = true |
53 | 54 | ||
54 | this.logsService.getLogs({ isAuditLog: this.isAuditLog(), level: this.level, startDate: this.startDate }) | 55 | const tagsOneOf = this.tagsOneOf.length !== 0 |
55 | .subscribe({ | 56 | ? this.tagsOneOf |
56 | next: logs => { | 57 | : undefined |
57 | this.logs = logs | 58 | |
58 | 59 | this.logsService.getLogs({ | |
59 | setTimeout(() => { | 60 | isAuditLog: this.isAuditLog(), |
60 | this.logsElement.nativeElement.scrollIntoView({ block: 'end', inline: 'nearest' }) | 61 | level: this.level, |
61 | }) | 62 | startDate: this.startDate, |
62 | }, | 63 | tagsOneOf |
64 | }).subscribe({ | ||
65 | next: logs => { | ||
66 | this.logs = logs | ||
67 | |||
68 | setTimeout(() => { | ||
69 | this.logsElement.nativeElement.scrollIntoView({ block: 'end', inline: 'nearest' }) | ||
70 | }) | ||
71 | }, | ||
63 | 72 | ||
64 | error: err => this.notifier.error(err.message), | 73 | error: err => this.notifier.error(err.message), |
65 | 74 | ||
66 | complete: () => this.loading = false | 75 | complete: () => this.loading = false |
67 | }) | 76 | }) |
68 | } | 77 | } |
69 | 78 | ||
70 | isAuditLog () { | 79 | isAuditLog () { |
diff --git a/client/src/app/+admin/system/logs/logs.service.ts b/client/src/app/+admin/system/logs/logs.service.ts index 0c222cad2..ea7e08b9b 100644 --- a/client/src/app/+admin/system/logs/logs.service.ts +++ b/client/src/app/+admin/system/logs/logs.service.ts | |||
@@ -2,7 +2,7 @@ import { Observable } from 'rxjs' | |||
2 | import { catchError, map } from 'rxjs/operators' | 2 | import { catchError, map } from 'rxjs/operators' |
3 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { RestExtractor } from '@app/core' | 5 | import { RestExtractor, RestService } from '@app/core' |
6 | import { LogLevel } from '@shared/models' | 6 | import { LogLevel } from '@shared/models' |
7 | import { environment } from '../../../../environments/environment' | 7 | import { environment } from '../../../../environments/environment' |
8 | import { LogRow } from './log-row.model' | 8 | import { LogRow } from './log-row.model' |
@@ -14,22 +14,25 @@ export class LogsService { | |||
14 | 14 | ||
15 | constructor ( | 15 | constructor ( |
16 | private authHttp: HttpClient, | 16 | private authHttp: HttpClient, |
17 | private restService: RestService, | ||
17 | private restExtractor: RestExtractor | 18 | private restExtractor: RestExtractor |
18 | ) {} | 19 | ) {} |
19 | 20 | ||
20 | getLogs (options: { | 21 | getLogs (options: { |
21 | isAuditLog: boolean | 22 | isAuditLog: boolean |
22 | startDate: string | 23 | startDate: string |
24 | tagsOneOf?: string[] | ||
23 | level?: LogLevel | 25 | level?: LogLevel |
24 | endDate?: string | 26 | endDate?: string |
25 | }): Observable<any[]> { | 27 | }): Observable<any[]> { |
26 | const { isAuditLog, startDate } = options | 28 | const { isAuditLog, startDate, endDate, tagsOneOf } = options |
27 | 29 | ||
28 | let params = new HttpParams() | 30 | let params = new HttpParams() |
29 | params = params.append('startDate', startDate) | 31 | params = params.append('startDate', startDate) |
30 | 32 | ||
31 | if (!isAuditLog) params = params.append('level', options.level) | 33 | if (!isAuditLog) params = params.append('level', options.level) |
32 | if (options.endDate) params.append('endDate', options.endDate) | 34 | if (endDate) params = params.append('endDate', options.endDate) |
35 | if (tagsOneOf) params = this.restService.addArrayParams(params, 'tagsOneOf', tagsOneOf) | ||
33 | 36 | ||
34 | const path = isAuditLog | 37 | const path = isAuditLog |
35 | ? LogsService.BASE_AUDIT_LOG_URL | 38 | ? LogsService.BASE_AUDIT_LOG_URL |
diff --git a/client/src/app/shared/shared-forms/select/select-tags.component.html b/client/src/app/shared/shared-forms/select/select-tags.component.html index e1cd50882..de6cee6db 100644 --- a/client/src/app/shared/shared-forms/select/select-tags.component.html +++ b/client/src/app/shared/shared-forms/select/select-tags.component.html | |||
@@ -2,7 +2,7 @@ | |||
2 | [items]="availableItems" | 2 | [items]="availableItems" |
3 | [(ngModel)]="selectedItems" | 3 | [(ngModel)]="selectedItems" |
4 | (ngModelChange)="onModelChange()" | 4 | (ngModelChange)="onModelChange()" |
5 | i18n-placeholder placeholder="Enter a new tag" | 5 | [placeholder]="placeholder" |
6 | [maxSelectedItems]="5" | 6 | [maxSelectedItems]="5" |
7 | [clearable]="true" | 7 | [clearable]="true" |
8 | [addTag]="true" | 8 | [addTag]="true" |
diff --git a/client/src/app/shared/shared-forms/select/select-tags.component.ts b/client/src/app/shared/shared-forms/select/select-tags.component.ts index 93d199037..bef04de8a 100644 --- a/client/src/app/shared/shared-forms/select/select-tags.component.ts +++ b/client/src/app/shared/shared-forms/select/select-tags.component.ts | |||
@@ -16,6 +16,7 @@ import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms' | |||
16 | export class SelectTagsComponent implements ControlValueAccessor { | 16 | export class SelectTagsComponent implements ControlValueAccessor { |
17 | @Input() availableItems: string[] = [] | 17 | @Input() availableItems: string[] = [] |
18 | @Input() selectedItems: string[] = [] | 18 | @Input() selectedItems: string[] = [] |
19 | @Input() placeholder = $localize`Enter a new tag` | ||
19 | 20 | ||
20 | propagateChange = (_: any) => { /* empty */ } | 21 | propagateChange = (_: any) => { /* empty */ } |
21 | 22 | ||
diff --git a/server/controllers/api/server/logs.ts b/server/controllers/api/server/logs.ts index dfd5491aa..8aa4b7190 100644 --- a/server/controllers/api/server/logs.ts +++ b/server/controllers/api/server/logs.ts | |||
@@ -1,6 +1,7 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { readdir, readFile } from 'fs-extra' | 2 | import { readdir, readFile } from 'fs-extra' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
4 | import { logger, mtimeSortFilesDesc } from '@server/helpers/logger' | 5 | import { logger, mtimeSortFilesDesc } from '@server/helpers/logger' |
5 | import { LogLevel } from '../../../../shared/models/server/log-level.type' | 6 | import { LogLevel } from '../../../../shared/models/server/log-level.type' |
6 | import { UserRight } from '../../../../shared/models/users' | 7 | import { UserRight } from '../../../../shared/models/users' |
@@ -51,20 +52,27 @@ async function getLogs (req: express.Request, res: express.Response) { | |||
51 | startDateQuery: req.query.startDate, | 52 | startDateQuery: req.query.startDate, |
52 | endDateQuery: req.query.endDate, | 53 | endDateQuery: req.query.endDate, |
53 | level: req.query.level || 'info', | 54 | level: req.query.level || 'info', |
55 | tagsOneOf: req.query.tagsOneOf, | ||
54 | nameFilter: logNameFilter | 56 | nameFilter: logNameFilter |
55 | }) | 57 | }) |
56 | 58 | ||
57 | return res.json(output).end() | 59 | return res.json(output) |
58 | } | 60 | } |
59 | 61 | ||
60 | async function generateOutput (options: { | 62 | async function generateOutput (options: { |
61 | startDateQuery: string | 63 | startDateQuery: string |
62 | endDateQuery?: string | 64 | endDateQuery?: string |
65 | |||
63 | level: LogLevel | 66 | level: LogLevel |
64 | nameFilter: RegExp | 67 | nameFilter: RegExp |
68 | tagsOneOf?: string[] | ||
65 | }) { | 69 | }) { |
66 | const { startDateQuery, level, nameFilter } = options | 70 | const { startDateQuery, level, nameFilter } = options |
67 | 71 | ||
72 | const tagsOneOf = Array.isArray(options.tagsOneOf) && options.tagsOneOf.length !== 0 | ||
73 | ? new Set(options.tagsOneOf) | ||
74 | : undefined | ||
75 | |||
68 | const logFiles = await readdir(CONFIG.STORAGE.LOG_DIR) | 76 | const logFiles = await readdir(CONFIG.STORAGE.LOG_DIR) |
69 | const sortedLogFiles = await mtimeSortFilesDesc(logFiles, CONFIG.STORAGE.LOG_DIR) | 77 | const sortedLogFiles = await mtimeSortFilesDesc(logFiles, CONFIG.STORAGE.LOG_DIR) |
70 | let currentSize = 0 | 78 | let currentSize = 0 |
@@ -80,7 +88,7 @@ async function generateOutput (options: { | |||
80 | const path = join(CONFIG.STORAGE.LOG_DIR, meta.file) | 88 | const path = join(CONFIG.STORAGE.LOG_DIR, meta.file) |
81 | logger.debug('Opening %s to fetch logs.', path) | 89 | logger.debug('Opening %s to fetch logs.', path) |
82 | 90 | ||
83 | const result = await getOutputFromFile(path, startDate, endDate, level, currentSize) | 91 | const result = await getOutputFromFile({ path, startDate, endDate, level, currentSize, tagsOneOf }) |
84 | if (!result.output) break | 92 | if (!result.output) break |
85 | 93 | ||
86 | output = result.output.concat(output) | 94 | output = result.output.concat(output) |
@@ -92,9 +100,20 @@ async function generateOutput (options: { | |||
92 | return output | 100 | return output |
93 | } | 101 | } |
94 | 102 | ||
95 | async function getOutputFromFile (path: string, startDate: Date, endDate: Date, level: LogLevel, currentSize: number) { | 103 | async function getOutputFromFile (options: { |
104 | path: string | ||
105 | startDate: Date | ||
106 | endDate: Date | ||
107 | level: LogLevel | ||
108 | currentSize: number | ||
109 | tagsOneOf: Set<string> | ||
110 | }) { | ||
111 | const { path, startDate, endDate, level, tagsOneOf } = options | ||
112 | |||
96 | const startTime = startDate.getTime() | 113 | const startTime = startDate.getTime() |
97 | const endTime = endDate.getTime() | 114 | const endTime = endDate.getTime() |
115 | let currentSize = options.currentSize | ||
116 | |||
98 | let logTime: number | 117 | let logTime: number |
99 | 118 | ||
100 | const logsLevel: { [ id in LogLevel ]: number } = { | 119 | const logsLevel: { [ id in LogLevel ]: number } = { |
@@ -121,7 +140,12 @@ async function getOutputFromFile (path: string, startDate: Date, endDate: Date, | |||
121 | } | 140 | } |
122 | 141 | ||
123 | logTime = new Date(log.timestamp).getTime() | 142 | logTime = new Date(log.timestamp).getTime() |
124 | if (logTime >= startTime && logTime <= endTime && logsLevel[log.level] >= logsLevel[level]) { | 143 | if ( |
144 | logTime >= startTime && | ||
145 | logTime <= endTime && | ||
146 | logsLevel[log.level] >= logsLevel[level] && | ||
147 | (!tagsOneOf || lineHasTag(log, tagsOneOf)) | ||
148 | ) { | ||
125 | output.push(log) | 149 | output.push(log) |
126 | 150 | ||
127 | currentSize += line.length | 151 | currentSize += line.length |
@@ -135,6 +159,16 @@ async function getOutputFromFile (path: string, startDate: Date, endDate: Date, | |||
135 | return { currentSize, output: output.reverse(), logTime } | 159 | return { currentSize, output: output.reverse(), logTime } |
136 | } | 160 | } |
137 | 161 | ||
162 | function lineHasTag (line: { tags?: string }, tagsOneOf: Set<string>) { | ||
163 | if (!isArray(line.tags)) return false | ||
164 | |||
165 | for (const lineTag of line.tags) { | ||
166 | if (tagsOneOf.has(lineTag)) return true | ||
167 | } | ||
168 | |||
169 | return false | ||
170 | } | ||
171 | |||
138 | function generateLogNameFilter (baseName: string) { | 172 | function generateLogNameFilter (baseName: string) { |
139 | return new RegExp('^' + baseName.replace(/\.log$/, '') + '\\d*.log$') | 173 | return new RegExp('^' + baseName.replace(/\.log$/, '') + '\\d*.log$') |
140 | } | 174 | } |
diff --git a/server/middlewares/validators/logs.ts b/server/middlewares/validators/logs.ts index 03c1c4df1..901d8ca64 100644 --- a/server/middlewares/validators/logs.ts +++ b/server/middlewares/validators/logs.ts | |||
@@ -1,7 +1,8 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { query } from 'express-validator' | 2 | import { query } from 'express-validator' |
3 | import { isStringArray } from '@server/helpers/custom-validators/search' | ||
3 | import { isValidLogLevel } from '../../helpers/custom-validators/logs' | 4 | import { isValidLogLevel } from '../../helpers/custom-validators/logs' |
4 | import { isDateValid } from '../../helpers/custom-validators/misc' | 5 | import { isDateValid, toArray } from '../../helpers/custom-validators/misc' |
5 | import { logger } from '../../helpers/logger' | 6 | import { logger } from '../../helpers/logger' |
6 | import { areValidationErrors } from './shared' | 7 | import { areValidationErrors } from './shared' |
7 | 8 | ||
@@ -11,6 +12,10 @@ const getLogsValidator = [ | |||
11 | query('level') | 12 | query('level') |
12 | .optional() | 13 | .optional() |
13 | .custom(isValidLogLevel).withMessage('Should have a valid level'), | 14 | .custom(isValidLogLevel).withMessage('Should have a valid level'), |
15 | query('tagsOneOf') | ||
16 | .optional() | ||
17 | .customSanitizer(toArray) | ||
18 | .custom(isStringArray).withMessage('Should have a valid one of tags array'), | ||
14 | query('endDate') | 19 | query('endDate') |
15 | .optional() | 20 | .optional() |
16 | .custom(isDateValid).withMessage('Should have an end date that conforms to ISO 8601'), | 21 | .custom(isDateValid).withMessage('Should have an end date that conforms to ISO 8601'), |
diff --git a/server/tests/api/server/logs.ts b/server/tests/api/server/logs.ts index bcd94dda3..4fa13886e 100644 --- a/server/tests/api/server/logs.ts +++ b/server/tests/api/server/logs.ts | |||
@@ -71,7 +71,7 @@ describe('Test logs', function () { | |||
71 | expect(logsString.includes('video 5')).to.be.false | 71 | expect(logsString.includes('video 5')).to.be.false |
72 | }) | 72 | }) |
73 | 73 | ||
74 | it('Should get filter by level', async function () { | 74 | it('Should filter by level', async function () { |
75 | this.timeout(20000) | 75 | this.timeout(20000) |
76 | 76 | ||
77 | const now = new Date() | 77 | const now = new Date() |
@@ -94,6 +94,27 @@ describe('Test logs', function () { | |||
94 | } | 94 | } |
95 | }) | 95 | }) |
96 | 96 | ||
97 | it('Should filter by tag', async function () { | ||
98 | const now = new Date() | ||
99 | |||
100 | const { uuid } = await server.videos.upload({ attributes: { name: 'video 6' } }) | ||
101 | await waitJobs([ server ]) | ||
102 | |||
103 | { | ||
104 | const body = await logsCommand.getLogs({ startDate: now, level: 'debug', tagsOneOf: [ 'toto' ] }) | ||
105 | expect(body).to.have.lengthOf(0) | ||
106 | } | ||
107 | |||
108 | { | ||
109 | const body = await logsCommand.getLogs({ startDate: now, level: 'debug', tagsOneOf: [ uuid ] }) | ||
110 | expect(body).to.not.have.lengthOf(0) | ||
111 | |||
112 | for (const line of body) { | ||
113 | expect(line.tags).to.contain(uuid) | ||
114 | } | ||
115 | } | ||
116 | }) | ||
117 | |||
97 | it('Should log ping requests', async function () { | 118 | it('Should log ping requests', async function () { |
98 | this.timeout(10000) | 119 | this.timeout(10000) |
99 | 120 | ||
diff --git a/shared/extra-utils/logs/logs-command.ts b/shared/extra-utils/logs/logs-command.ts index 5912e814f..7b5c66c0c 100644 --- a/shared/extra-utils/logs/logs-command.ts +++ b/shared/extra-utils/logs/logs-command.ts | |||
@@ -8,15 +8,16 @@ export class LogsCommand extends AbstractCommand { | |||
8 | startDate: Date | 8 | startDate: Date |
9 | endDate?: Date | 9 | endDate?: Date |
10 | level?: LogLevel | 10 | level?: LogLevel |
11 | tagsOneOf?: string[] | ||
11 | }) { | 12 | }) { |
12 | const { startDate, endDate, level } = options | 13 | const { startDate, endDate, tagsOneOf, level } = options |
13 | const path = '/api/v1/server/logs' | 14 | const path = '/api/v1/server/logs' |
14 | 15 | ||
15 | return this.getRequestBody({ | 16 | return this.getRequestBody<any[]>({ |
16 | ...options, | 17 | ...options, |
17 | 18 | ||
18 | path, | 19 | path, |
19 | query: { startDate, endDate, level }, | 20 | query: { startDate, endDate, level, tagsOneOf }, |
20 | implicitToken: true, | 21 | implicitToken: true, |
21 | defaultExpectedStatus: HttpStatusCode.OK_200 | 22 | defaultExpectedStatus: HttpStatusCode.OK_200 |
22 | }) | 23 | }) |