diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 66d108134..1283105e9 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html @@ -25,11 +25,11 @@
@{{ account.nameWithHost }} - + +
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss index 56b952b65..aadd6f5c0 100644 --- a/client/src/app/+accounts/accounts.component.scss +++ b/client/src/app/+accounts/accounts.component.scss @@ -28,14 +28,8 @@ } } -.copy-button { +my-copy-button { @include margin-left(3px); - - border: 0; - - my-global-icon { - width: 15px; - } } .account-info { diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 0033fbf59..6d912e325 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts @@ -115,10 +115,6 @@ export class AccountsComponent implements OnInit, OnDestroy { this.redirectService.redirectToHomepage() } - activateCopiedMessage () { - this.notifier.success($localize`Username copied`) - } - searchChanged (search: string) { const queryParams = { search } diff --git a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.html b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.html index a759e9186..1a40da0c0 100644 --- a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.html +++ b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.html @@ -31,7 +31,7 @@ Priority Progress Runner - Created + Created @@ -47,7 +47,7 @@
- +
@@ -73,7 +73,9 @@ {{ runnerJob.uuid }} {{ runnerJob.type }} - {{ runnerJob.state.label }} + + {{ runnerJob.state.label }} + {{ runnerJob.priority }} diff --git a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts index 8994c1d00..2670eac86 100644 --- a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts +++ b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts @@ -5,6 +5,7 @@ import { formatICU } from '@app/helpers' import { DropdownAction } from '@app/shared/shared-main' import { RunnerJob, RunnerJobState } from '@shared/models' import { RunnerJobFormatted, RunnerService } from '../runner.service' +import { AdvancedInputFilter } from '@app/shared/shared-forms' @Component({ selector: 'my-runner-job-list', @@ -20,6 +21,30 @@ export class RunnerJobListComponent extends RestTable implements OnI actions: DropdownAction[][] = [] bulkActions: DropdownAction[][] = [] + inputFilters: AdvancedInputFilter[] = [ + { + title: $localize`Advanced filters`, + children: [ + { + value: 'state:completed', + label: $localize`Completed jobs` + }, + { + value: 'state:pending state:waiting-for-parent-job', + label: $localize`Pending jobs` + }, + { + value: 'state:processing', + label: $localize`Jobs that are being processed` + }, + { + value: 'state:errored state:parent-errored', + label: $localize`Failed jobs` + } + ] + } + ] + constructor ( private runnerService: RunnerService, private notifier: Notifier, @@ -36,6 +61,12 @@ export class RunnerJobListComponent extends RestTable implements OnI handler: job => this.cancelJobs([ job ]), isDisplayed: job => this.canCancelJob(job) } + ], + [ + { + label: $localize`Delete this job`, + handler: job => this.removeJobs([ job ]) + } ] ] @@ -46,6 +77,12 @@ export class RunnerJobListComponent extends RestTable implements OnI handler: jobs => this.cancelJobs(jobs), isDisplayed: jobs => jobs.every(j => this.canCancelJob(j)) } + ], + [ + { + label: $localize`Delete`, + handler: jobs => this.removeJobs(jobs) + } ] ] @@ -77,6 +114,45 @@ export class RunnerJobListComponent extends RestTable implements OnI }) } + async removeJobs (jobs: RunnerJob[]) { + const message = formatICU( + $localize`Do you really want to remove {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be removed.`, + { count: jobs.length } + ) + + const res = await this.confirmService.confirm(message, $localize`Remove`) + + if (res === false) return + + this.runnerService.removeJobs(jobs) + .subscribe({ + next: () => { + this.reloadData() + this.notifier.success($localize`Job(s) removed.`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + getStateBadgeColor (job: RunnerJob) { + switch (job.state.id) { + case RunnerJobState.ERRORED: + case RunnerJobState.PARENT_ERRORED: + return 'badge-danger' + + case RunnerJobState.COMPLETED: + return 'badge-success' + + case RunnerJobState.PENDING: + case RunnerJobState.WAITING_FOR_PARENT_JOB: + return 'badge-warning' + + default: + return 'badge-info' + } + } + protected reloadDataInternal () { this.runnerService.listRunnerJobs({ pagination: this.pagination, sort: this.sort, search: this.search }) .subscribe({ diff --git a/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.html b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.html index 3e5cea881..4ff2c3b4c 100644 --- a/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.html +++ b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.html @@ -45,7 +45,14 @@ > - {{ registrationToken.registrationToken }} + + {{ registrationToken.registrationToken }} + + + {{ registrationToken.createdAt | date: 'short' }} diff --git a/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.scss b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.scss new file mode 100644 index 000000000..1cfb2e65f --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.scss @@ -0,0 +1,12 @@ +@use '_variables' as *; +@use '_mixins' as *; + +my-copy-button { + @include margin-left(3px); +} + +tr:not(:hover) { + my-copy-button { + opacity: 0; + } +} diff --git a/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.ts b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.ts index f03aab189..77908a2e1 100644 --- a/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.ts +++ b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.ts @@ -7,6 +7,7 @@ import { RunnerService } from '../runner.service' @Component({ selector: 'my-runner-registration-token-list', + styleUrls: [ './runner-registration-token-list.component.scss' ], templateUrl: './runner-registration-token-list.component.html' }) export class RunnerRegistrationTokenListComponent extends RestTable implements OnInit { diff --git a/client/src/app/+admin/system/runners/runner.service.ts b/client/src/app/+admin/system/runners/runner.service.ts index 392ec82bc..3ab36c4ff 100644 --- a/client/src/app/+admin/system/runners/runner.service.ts +++ b/client/src/app/+admin/system/runners/runner.service.ts @@ -6,7 +6,7 @@ import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService, ServerService } from '@app/core' import { arrayify, peertubeTranslate } from '@shared/core-utils' import { ResultList } from '@shared/models/common' -import { Runner, RunnerJob, RunnerJobAdmin, RunnerRegistrationToken } from '@shared/models/runners' +import { Runner, RunnerJob, RunnerJobAdmin, RunnerJobState, RunnerRegistrationToken } from '@shared/models/runners' import { environment } from '../../../../environments/environment' export type RunnerJobFormatted = RunnerJob & { @@ -60,7 +60,9 @@ export class RunnerService { let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) - if (search) params = params.append('search', search) + if (search) { + params = this.buildParamsFromSearch(search, params) + } return forkJoin([ this.authHttp.get>(RunnerService.BASE_RUNNER_URL + '/jobs', { params }), @@ -90,6 +92,31 @@ export class RunnerService { ) } + private buildParamsFromSearch (search: string, params: HttpParams) { + const filters = this.restService.parseQueryStringFilter(search, { + stateOneOf: { + prefix: 'state:', + multiple: true, + handler: v => { + if (v === 'completed') return RunnerJobState.COMPLETED + if (v === 'processing') return RunnerJobState.PROCESSING + if (v === 'errored') return RunnerJobState.ERRORED + if (v === 'pending') return RunnerJobState.PENDING + if (v === 'waiting-for-parent-job') return RunnerJobState.WAITING_FOR_PARENT_JOB + if (v === 'parent-errored') return RunnerJobState.PARENT_ERRORED + + return undefined + } + } + }) + + console.log(filters) + + return this.restService.addObjectParams(params, filters) + } + + // --------------------------------------------------------------------------- + cancelJobs (jobsArg: RunnerJob | RunnerJob[]) { const jobs = arrayify(jobsArg) @@ -101,6 +128,17 @@ export class RunnerService { ) } + removeJobs (jobsArg: RunnerJob | RunnerJob[]) { + const jobs = arrayify(jobsArg) + + return from(jobs) + .pipe( + concatMap(job => this.authHttp.delete(RunnerService.BASE_RUNNER_URL + '/jobs/' + job.uuid)), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) + } + // --------------------------------------------------------------------------- listRunners (options: { diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index fff160f2e..228cc4edd 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html @@ -64,11 +64,11 @@
@{{ videoChannel.nameWithHost }} - + +
diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss index aba266fcc..182e8d845 100644 --- a/client/src/app/+video-channels/video-channels.component.scss +++ b/client/src/app/+video-channels/video-channels.component.scss @@ -152,14 +152,8 @@ display: none; } -.copy-button { +my-copy-button { @include margin-left(3px); - - border: 0; - - my-global-icon { - width: 15px; - } } @media screen and (max-width: 1400px) { diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index afbf96032..f5bea66ec 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts @@ -120,10 +120,6 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { return this.isOwner() || this.authService.getUser().hasRight(UserRight.MANAGE_ANY_VIDEO_CHANNEL) } - activateCopiedMessage () { - this.notifier.success($localize`Username copied`) - } - hasShowMoreDescription () { return !this.channelDescriptionExpanded && this.channelDescriptionHTML.length > 100 } diff --git a/client/src/app/core/rest/rest.service.ts b/client/src/app/core/rest/rest.service.ts index d8b5ffb18..f07afb7e8 100644 --- a/client/src/app/core/rest/rest.service.ts +++ b/client/src/app/core/rest/rest.service.ts @@ -7,16 +7,18 @@ import { RestPagination } from './rest-pagination' const debugLogger = debug('peertube:rest') +type ParseQueryHandlerResult = string | number | boolean | string[] | number[] | boolean[] + interface QueryStringFilterPrefixes { [key: string]: { prefix: string - handler?: (v: string) => string | number | boolean + handler?: (v: string) => ParseQueryHandlerResult multiple?: boolean isBoolean?: boolean } } -type ParseQueryStringFilters = Partial> +type ParseQueryStringFilters = Partial> type ParseQueryStringFiltersResult = ParseQueryStringFilters & { search?: string } @Injectable() diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.scss b/client/src/app/shared/shared-forms/advanced-input-filter.component.scss index 4efbeb85d..f5438ffdb 100644 --- a/client/src/app/shared/shared-forms/advanced-input-filter.component.scss +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.scss @@ -33,6 +33,5 @@ my-global-icon { div[role=menu] { max-height: 50vh; - min-height: 200px; overflow: auto; } diff --git a/client/src/app/shared/shared-forms/input-text.component.html b/client/src/app/shared/shared-forms/input-text.component.html index abb53a085..4747e2f8f 100644 --- a/client/src/app/shared/shared-forms/input-text.component.html +++ b/client/src/app/shared/shared-forms/input-text.component.html @@ -11,13 +11,12 @@ - + COPY +
{{ formError }}
diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts index aa4a1cba8..be03f25b9 100644 --- a/client/src/app/shared/shared-forms/input-text.component.ts +++ b/client/src/app/shared/shared-forms/input-text.component.ts @@ -46,10 +46,6 @@ export class InputTextComponent implements ControlValueAccessor { this.show = !this.show } - activateCopiedMessage () { - this.notifier.success($localize`Copied`) - } - propagateChange = (_: any) => { /* empty */ } writeValue (value: string) { diff --git a/client/src/app/shared/shared-main/buttons/copy-button.component.html b/client/src/app/shared/shared-main/buttons/copy-button.component.html new file mode 100644 index 000000000..a99c0a93a --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/copy-button.component.html @@ -0,0 +1,9 @@ + diff --git a/client/src/app/shared/shared-main/buttons/copy-button.component.scss b/client/src/app/shared/shared-main/buttons/copy-button.component.scss new file mode 100644 index 000000000..7e3720418 --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/copy-button.component.scss @@ -0,0 +1,15 @@ +@use '_variables' as *; +@use '_mixins' as *; + +button:not(.is-input-group) { + border: 0; +} + +.is-input-group { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +my-global-icon { + width: 15px; +} diff --git a/client/src/app/shared/shared-main/buttons/copy-button.component.ts b/client/src/app/shared/shared-main/buttons/copy-button.component.ts new file mode 100644 index 000000000..aac9ab8b0 --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/copy-button.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core' +import { Notifier } from '@app/core' + +@Component({ + selector: 'my-copy-button', + styleUrls: [ './copy-button.component.scss' ], + templateUrl: './copy-button.component.html' +}) +export class CopyButtonComponent { + @Input() value: string + @Input() title: string + @Input() notification: string + @Input() isInputGroup = false + + constructor (private notifier: Notifier) { + + } + + activateCopiedMessage () { + if (this.notification) this.notifier.success(this.notification) + } +} diff --git a/client/src/app/shared/shared-main/buttons/index.ts b/client/src/app/shared/shared-main/buttons/index.ts index 775a47a39..75efbdea3 100644 --- a/client/src/app/shared/shared-main/buttons/index.ts +++ b/client/src/app/shared/shared-main/buttons/index.ts @@ -1,4 +1,5 @@ export * from './action-dropdown.component' export * from './button.component' +export * from './copy-button.component' export * from './delete-button.component' export * from './edit-button.component' diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 480277450..243394bda 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -31,7 +31,7 @@ import { PeerTubeTemplateDirective } from './angular' import { AUTH_INTERCEPTOR_PROVIDER } from './auth' -import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' +import { ActionDropdownComponent, ButtonComponent, CopyButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' import { CustomPageService } from './custom-page' import { DateToggleComponent } from './date' import { FeedComponent } from './feeds' @@ -100,6 +100,7 @@ import { VideoChannelService } from './video-channel' ActionDropdownComponent, ButtonComponent, + CopyButtonComponent, DeleteButtonComponent, EditButtonComponent, @@ -162,6 +163,7 @@ import { VideoChannelService } from './video-channel' ActionDropdownComponent, ButtonComponent, + CopyButtonComponent, DeleteButtonComponent, EditButtonComponent, diff --git a/server/controllers/api/runners/jobs.ts b/server/controllers/api/runners/jobs.ts index be5911b53..e9e2ddf49 100644 --- a/server/controllers/api/runners/jobs.ts +++ b/server/controllers/api/runners/jobs.ts @@ -5,7 +5,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger' import { generateRunnerJobToken } from '@server/helpers/token-generator' import { MIMETYPES } from '@server/initializers/constants' import { sequelizeTypescript } from '@server/initializers/database' -import { getRunnerJobHandlerClass, updateLastRunnerContact } from '@server/lib/runners' +import { getRunnerJobHandlerClass, runnerJobCanBeCancelled, updateLastRunnerContact } from '@server/lib/runners' import { apiRateLimiter, asyncMiddleware, @@ -23,6 +23,7 @@ import { errorRunnerJobValidator, getRunnerFromTokenValidator, jobOfRunnerGetValidatorFactory, + listRunnerJobsValidator, runnerJobGetValidator, successRunnerJobValidator, updateRunnerJobValidator @@ -131,9 +132,17 @@ runnerJobsRouter.get('/jobs', runnerJobsSortValidator, setDefaultSort, setDefaultPagination, + listRunnerJobsValidator, asyncMiddleware(listRunnerJobs) ) +runnerJobsRouter.delete('/jobs/:jobUUID', + authenticate, + ensureUserHasRight(UserRight.MANAGE_RUNNERS), + asyncMiddleware(runnerJobGetValidator), + asyncMiddleware(deleteRunnerJob) +) + // --------------------------------------------------------------------------- export { @@ -374,6 +383,21 @@ async function cancelRunnerJob (req: express.Request, res: express.Response) { return res.sendStatus(HttpStatusCode.NO_CONTENT_204) } +async function deleteRunnerJob (req: express.Request, res: express.Response) { + const runnerJob = res.locals.runnerJob + + logger.info('Deleting job %s (%s)', runnerJob.uuid, runnerJob.type, lTags(runnerJob.uuid, runnerJob.type)) + + if (runnerJobCanBeCancelled(runnerJob)) { + const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob) + await new RunnerJobHandler().cancel({ runnerJob }) + } + + await runnerJob.destroy() + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + async function listRunnerJobs (req: express.Request, res: express.Response) { const query: ListRunnerJobsQuery = req.query @@ -381,7 +405,8 @@ async function listRunnerJobs (req: express.Request, res: express.Response) { start: query.start, count: query.count, sort: query.sort, - search: query.search + search: query.search, + stateOneOf: query.stateOneOf }) return res.json({ diff --git a/server/helpers/custom-validators/runners/jobs.ts b/server/helpers/custom-validators/runners/jobs.ts index 725a7658f..6349e79ba 100644 --- a/server/helpers/custom-validators/runners/jobs.ts +++ b/server/helpers/custom-validators/runners/jobs.ts @@ -1,6 +1,6 @@ import { UploadFilesForCheck } from 'express' import validator from 'validator' -import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' +import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants' import { LiveRTMPHLSTranscodingSuccess, RunnerJobSuccessPayload, @@ -11,7 +11,7 @@ import { VODHLSTranscodingSuccess, VODWebVideoTranscodingSuccess } from '@shared/models' -import { exists, isFileValid, isSafeFilename } from '../misc' +import { exists, isArray, isFileValid, isSafeFilename } from '../misc' const RUNNER_JOBS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.RUNNER_JOBS @@ -56,6 +56,14 @@ function isRunnerJobErrorMessageValid (value: string) { return validator.isLength(value, RUNNER_JOBS_CONSTRAINTS_FIELDS.ERROR_MESSAGE) } +function isRunnerJobStateValid (value: any) { + return exists(value) && RUNNER_JOB_STATES[value] !== undefined +} + +function isRunnerJobArrayOfStateValid (value: any) { + return isArray(value) && value.every(v => isRunnerJobStateValid(v)) +} + // --------------------------------------------------------------------------- export { @@ -65,7 +73,9 @@ export { isRunnerJobTokenValid, isRunnerJobErrorMessageValid, isRunnerJobProgressValid, - isRunnerJobAbortReasonValid + isRunnerJobAbortReasonValid, + isRunnerJobArrayOfStateValid, + isRunnerJobStateValid } // --------------------------------------------------------------------------- diff --git a/server/lib/runners/runner.ts b/server/lib/runners/runner.ts index 921cae6f2..947fdb3f0 100644 --- a/server/lib/runners/runner.ts +++ b/server/lib/runners/runner.ts @@ -2,8 +2,9 @@ import express from 'express' import { retryTransactionWrapper } from '@server/helpers/database-utils' import { logger, loggerTagsFactory } from '@server/helpers/logger' import { sequelizeTypescript } from '@server/initializers/database' -import { MRunner } from '@server/types/models/runners' +import { MRunner, MRunnerJob } from '@server/types/models/runners' import { RUNNER_JOBS } from '@server/initializers/constants' +import { RunnerJobState } from '@shared/models' const lTags = loggerTagsFactory('runner') @@ -32,6 +33,17 @@ function updateLastRunnerContact (req: express.Request, runner: MRunner) { .finally(() => updatingRunner.delete(runner.id)) } -export { - updateLastRunnerContact +function runnerJobCanBeCancelled (runnerJob: MRunnerJob) { + const allowedStates = new Set([ + RunnerJobState.PENDING, + RunnerJobState.PROCESSING, + RunnerJobState.WAITING_FOR_PARENT_JOB + ]) + + return allowedStates.has(runnerJob.state) +} + +export { + updateLastRunnerContact, + runnerJobCanBeCancelled } diff --git a/server/middlewares/validators/runners/jobs.ts b/server/middlewares/validators/runners/jobs.ts index 384b209ba..62f9340a5 100644 --- a/server/middlewares/validators/runners/jobs.ts +++ b/server/middlewares/validators/runners/jobs.ts @@ -1,8 +1,9 @@ import express from 'express' -import { body, param } from 'express-validator' -import { isUUIDValid } from '@server/helpers/custom-validators/misc' +import { body, param, query } from 'express-validator' +import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc' import { isRunnerJobAbortReasonValid, + isRunnerJobArrayOfStateValid, isRunnerJobErrorMessageValid, isRunnerJobProgressValid, isRunnerJobSuccessPayloadValid, @@ -12,7 +13,9 @@ import { import { isRunnerTokenValid } from '@server/helpers/custom-validators/runners/runners' import { cleanUpReqFiles } from '@server/helpers/express-utils' import { LiveManager } from '@server/lib/live' +import { runnerJobCanBeCancelled } from '@server/lib/runners' import { RunnerJobModel } from '@server/models/runner/runner-job' +import { arrayify } from '@shared/core-utils' import { HttpStatusCode, RunnerJobLiveRTMPHLSTranscodingPrivatePayload, @@ -119,13 +122,7 @@ export const cancelRunnerJobValidator = [ (req: express.Request, res: express.Response, next: express.NextFunction) => { const runnerJob = res.locals.runnerJob - const allowedStates = new Set([ - RunnerJobState.PENDING, - RunnerJobState.PROCESSING, - RunnerJobState.WAITING_FOR_PARENT_JOB - ]) - - if (allowedStates.has(runnerJob.state) !== true) { + if (runnerJobCanBeCancelled(runnerJob) !== true) { return res.fail({ status: HttpStatusCode.BAD_REQUEST_400, message: 'Cannot cancel this job that is not in "pending", "processing" or "waiting for parent job" state', @@ -137,6 +134,21 @@ export const cancelRunnerJobValidator = [ } ] +export const listRunnerJobsValidator = [ + query('search') + .optional() + .custom(exists), + + query('stateOneOf') + .optional() + .customSanitizer(arrayify) + .custom(isRunnerJobArrayOfStateValid), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + return next() + } +] + export const runnerJobGetValidator = [ param('jobUUID').custom(isUUIDValid), diff --git a/server/models/runner/runner-job.ts b/server/models/runner/runner-job.ts index add6f9a43..f2ffd6a84 100644 --- a/server/models/runner/runner-job.ts +++ b/server/models/runner/runner-job.ts @@ -1,4 +1,4 @@ -import { FindOptions, Op, Transaction } from 'sequelize' +import { Op, Transaction } from 'sequelize' import { AllowNull, BelongsTo, @@ -13,7 +13,7 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import { isUUIDValid } from '@server/helpers/custom-validators/misc' +import { isArray, isUUIDValid } from '@server/helpers/custom-validators/misc' import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants' import { MRunnerJob, MRunnerJobRunner, MRunnerJobRunnerParent } from '@server/types/models/runners' import { RunnerJob, RunnerJobAdmin, RunnerJobPayload, RunnerJobPrivatePayload, RunnerJobState, RunnerJobType } from '@shared/models' @@ -227,28 +227,38 @@ export class RunnerJobModel extends Model count: number sort: string search?: string + stateOneOf?: RunnerJobState[] }) { - const { start, count, sort, search } = options + const { start, count, sort, search, stateOneOf } = options - const query: FindOptions = { + const query = { offset: start, limit: count, - order: getSort(sort) + order: getSort(sort), + where: [] } if (search) { if (isUUIDValid(search)) { - query.where = { uuid: search } + query.where.push({ uuid: search }) } else { - query.where = { + query.where.push({ [Op.or]: [ searchAttribute(search, 'type'), searchAttribute(search, '$Runner.name$') ] - } + }) } } + if (isArray(stateOneOf) && stateOneOf.length !== 0) { + query.where.push({ + state: { + [Op.in]: stateOneOf + } + }) + } + return Promise.all([ RunnerJobModel.scope([ ScopeNames.WITH_RUNNER ]).count(query), RunnerJobModel.scope([ ScopeNames.WITH_RUNNER, ScopeNames.WITH_PARENT ]).findAll(query) diff --git a/server/tests/api/check-params/runners.ts b/server/tests/api/check-params/runners.ts index 9112ff716..7f9a0cd32 100644 --- a/server/tests/api/check-params/runners.ts +++ b/server/tests/api/check-params/runners.ts @@ -1,14 +1,14 @@ -import { basename } from 'path' /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { basename } from 'path' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared' import { HttpStatusCode, isVideoStudioTaskIntro, RunnerJob, RunnerJobState, + RunnerJobStudioTranscodingPayload, RunnerJobSuccessPayload, RunnerJobUpdatePayload, - RunnerJobStudioTranscodingPayload, VideoPrivacy, VideoStudioTaskIntro } from '@shared/models' @@ -236,6 +236,10 @@ describe('Test managing runners', function () { await checkBadSortPagination(server.url, path, server.accessToken) }) + it('Should fail with an invalid state', async function () { + await server.runners.list({ start: 0, count: 5, sort: '-createdAt' }) + }) + it('Should succeed to list with the correct params', async function () { await server.runners.list({ start: 0, count: 5, sort: '-createdAt' }) }) @@ -307,8 +311,48 @@ describe('Test managing runners', function () { await checkBadSortPagination(server.url, path, server.accessToken) }) - it('Should succeed to list with the correct params', async function () { - await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt' }) + it('Should fail with an invalid state', async function () { + await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt', stateOneOf: 42 as any }) + await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt', stateOneOf: [ 42 ] as any }) + }) + + it('Should succeed with the correct params', async function () { + await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt', stateOneOf: [ RunnerJobState.COMPLETED ] }) + }) + }) + + describe('Delete', function () { + let jobUUID: string + + before(async function () { + this.timeout(60000) + + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + }) + + it('Should fail without oauth token', async function () { + await server.runnerJobs.deleteByAdmin({ token: null, jobUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without admin rights', async function () { + await server.runnerJobs.deleteByAdmin({ token: userToken, jobUUID, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad job uuid', async function () { + await server.runnerJobs.deleteByAdmin({ jobUUID: 'hello', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown job uuid', async function () { + const jobUUID = badUUID + await server.runnerJobs.deleteByAdmin({ jobUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await server.runnerJobs.deleteByAdmin({ jobUUID }) }) }) diff --git a/server/tests/api/runners/runner-common.ts b/server/tests/api/runners/runner-common.ts index 34a51abe7..7fed75f40 100644 --- a/server/tests/api/runners/runner-common.ts +++ b/server/tests/api/runners/runner-common.ts @@ -339,6 +339,30 @@ describe('Test runner common actions', function () { expect(data).to.not.have.lengthOf(0) expect(total).to.not.equal(0) + + for (const job of data) { + expect(job.type).to.include('hls') + } + } + }) + + it('Should filter jobs', async function () { + { + const { total, data } = await server.runnerJobs.list({ stateOneOf: [ RunnerJobState.WAITING_FOR_PARENT_JOB ] }) + + expect(data).to.not.have.lengthOf(0) + expect(total).to.not.equal(0) + + for (const job of data) { + expect(job.state.label).to.equal('Waiting for parent job to finish') + } + } + + { + const { total, data } = await server.runnerJobs.list({ stateOneOf: [ RunnerJobState.COMPLETED ] }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) } }) }) @@ -598,6 +622,33 @@ describe('Test runner common actions', function () { }) }) + describe('Remove', function () { + + it('Should remove a pending job', async function () { + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + const pendingJob = data.find(j => j.state.id === RunnerJobState.PENDING) + jobUUID = pendingJob.uuid + + await server.runnerJobs.deleteByAdmin({ jobUUID }) + } + + { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + const parent = data.find(j => j.uuid === jobUUID) + expect(parent).to.not.exist + + const children = data.filter(j => j.parent?.uuid === jobUUID) + expect(children).to.have.lengthOf(0) + } + }) + }) + describe('Stalled jobs', function () { it('Should abort stalled jobs', async function () { diff --git a/shared/models/runners/list-runner-jobs-query.model.ts b/shared/models/runners/list-runner-jobs-query.model.ts index a5b62c55d..ef19b31fa 100644 --- a/shared/models/runners/list-runner-jobs-query.model.ts +++ b/shared/models/runners/list-runner-jobs-query.model.ts @@ -1,6 +1,9 @@ +import { RunnerJobState } from './runner-job-state.model' + export interface ListRunnerJobsQuery { start?: number count?: number sort?: string search?: string + stateOneOf?: RunnerJobState[] } diff --git a/shared/server-commands/runners/runner-jobs-command.ts b/shared/server-commands/runners/runner-jobs-command.ts index 26dbef77a..0a0ffb5d3 100644 --- a/shared/server-commands/runners/runner-jobs-command.ts +++ b/shared/server-commands/runners/runner-jobs-command.ts @@ -8,6 +8,7 @@ import { isHLSTranscodingPayloadSuccess, isLiveRTMPHLSTranscodingUpdatePayload, isWebVideoOrAudioMergeTranscodingPayloadSuccess, + ListRunnerJobsQuery, RequestRunnerJobBody, RequestRunnerJobResult, ResultList, @@ -27,19 +28,14 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared' export class RunnerJobsCommand extends AbstractCommand { - list (options: OverrideCommandOptions & { - start?: number - count?: number - sort?: string - search?: string - } = {}) { + list (options: OverrideCommandOptions & ListRunnerJobsQuery = {}) { const path = '/api/v1/runners/jobs' return this.getRequestBody>({ ...options, path, - query: pick(options, [ 'start', 'count', 'sort', 'search' ]), + query: pick(options, [ 'start', 'count', 'sort', 'search', 'stateOneOf' ]), implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 }) @@ -57,6 +53,18 @@ export class RunnerJobsCommand extends AbstractCommand { }) } + deleteByAdmin (options: OverrideCommandOptions & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + // --------------------------------------------------------------------------- request (options: OverrideCommandOptions & RequestRunnerJobBody) { diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 654bd7461..44daecf85 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -6088,6 +6088,21 @@ paths: '204': description: successful operation + /api/v1/runners/jobs/{jobUUID}: + delete: + summary: Delete a job + description: The endpoint will first cancel the job if needed, and then remove it from the database. Children jobs will also be removed + security: + - OAuth2: + - admin + tags: + - Runner Jobs + parameters: + - $ref: '#/components/parameters/jobUUID' + responses: + '204': + description: successful operation + /api/v1/runners/jobs: get: summary: List jobs @@ -6101,6 +6116,13 @@ paths: - $ref: '#/components/parameters/count' - $ref: '#/components/parameters/runnerJobSort' - $ref: '#/components/parameters/search' + - name: stateOneOf + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/RunnerJobState' responses: '200': description: successful operation