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