1
0
Fork 0

Improve runner management

* Add ability to remove runner jobs
 * Add runner job state quick filter
 * Merge registration tokens and runners tables in the same page
 * Add copy button to copy registration token
This commit is contained in:
Chocobozzz 2023-07-27 11:44:31 +02:00
parent f5af5feb5a
commit f18003d0ac
No known key found for this signature in database
GPG key ID: 583A612D890159BE
31 changed files with 446 additions and 88 deletions

View file

@ -25,11 +25,11 @@
<div class="actor-handle"> <div class="actor-handle">
<span>@{{ account.nameWithHost }}</span> <span>@{{ account.nameWithHost }}</span>
<button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
class="btn btn-outline-secondary btn-sm copy-button" title="Copy account handle" i18n-title <my-copy-button
> [value]="account.nameWithHostForced" i18n-notification notification="Username copied"
<my-global-icon iconName="copy"></my-global-icon> title="Copy account handle" i18n-title
</button> ></my-copy-button>
</div> </div>
<div class="actor-counters"> <div class="actor-counters">

View file

@ -28,14 +28,8 @@
} }
} }
.copy-button { my-copy-button {
@include margin-left(3px); @include margin-left(3px);
border: 0;
my-global-icon {
width: 15px;
}
} }
.account-info { .account-info {

View file

@ -115,10 +115,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
this.redirectService.redirectToHomepage() this.redirectService.redirectToHomepage()
} }
activateCopiedMessage () {
this.notifier.success($localize`Username copied`)
}
searchChanged (search: string) { searchChanged (search: string) {
const queryParams = { search } const queryParams = { search }

View file

@ -31,7 +31,7 @@
<th style="width: 100px" i18n pSortableColumn="priority">Priority <p-sortIcon field="priority"></p-sortIcon></th> <th style="width: 100px" i18n pSortableColumn="priority">Priority <p-sortIcon field="priority"></p-sortIcon></th>
<th style="width: 100px" i18n pSortableColumn="progress">Progress <p-sortIcon field="progress"></p-sortIcon></th> <th style="width: 100px" i18n pSortableColumn="progress">Progress <p-sortIcon field="progress"></p-sortIcon></th>
<th i18n>Runner</th> <th i18n>Runner</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 200px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr> </tr>
</ng-template> </ng-template>
@ -47,7 +47,7 @@
</div> </div>
<div class="ms-auto d-flex"> <div class="ms-auto d-flex">
<my-advanced-input-filter class="me-2" (search)="onSearch($event)"></my-advanced-input-filter> <my-advanced-input-filter class="me-2" [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
<my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button> <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
</div> </div>
@ -73,7 +73,9 @@
<td>{{ runnerJob.uuid }}</td> <td>{{ runnerJob.uuid }}</td>
<td>{{ runnerJob.type }}</td> <td>{{ runnerJob.type }}</td>
<td>{{ runnerJob.state.label }}</td> <td>
<span class="pt-badge" [ngClass]="getStateBadgeColor(runnerJob)">{{ runnerJob.state.label }}</span>
</td>
<td>{{ runnerJob.priority }}</td> <td>{{ runnerJob.priority }}</td>
<td> <td>

View file

@ -5,6 +5,7 @@ import { formatICU } from '@app/helpers'
import { DropdownAction } from '@app/shared/shared-main' import { DropdownAction } from '@app/shared/shared-main'
import { RunnerJob, RunnerJobState } from '@shared/models' import { RunnerJob, RunnerJobState } from '@shared/models'
import { RunnerJobFormatted, RunnerService } from '../runner.service' import { RunnerJobFormatted, RunnerService } from '../runner.service'
import { AdvancedInputFilter } from '@app/shared/shared-forms'
@Component({ @Component({
selector: 'my-runner-job-list', selector: 'my-runner-job-list',
@ -20,6 +21,30 @@ export class RunnerJobListComponent extends RestTable <RunnerJob> implements OnI
actions: DropdownAction<RunnerJob>[][] = [] actions: DropdownAction<RunnerJob>[][] = []
bulkActions: DropdownAction<RunnerJob[]>[][] = [] bulkActions: DropdownAction<RunnerJob[]>[][] = []
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 ( constructor (
private runnerService: RunnerService, private runnerService: RunnerService,
private notifier: Notifier, private notifier: Notifier,
@ -36,6 +61,12 @@ export class RunnerJobListComponent extends RestTable <RunnerJob> implements OnI
handler: job => this.cancelJobs([ job ]), handler: job => this.cancelJobs([ job ]),
isDisplayed: job => this.canCancelJob(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 <RunnerJob> implements OnI
handler: jobs => this.cancelJobs(jobs), handler: jobs => this.cancelJobs(jobs),
isDisplayed: jobs => jobs.every(j => this.canCancelJob(j)) 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 <RunnerJob> 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 () { protected reloadDataInternal () {
this.runnerService.listRunnerJobs({ pagination: this.pagination, sort: this.sort, search: this.search }) this.runnerService.listRunnerJobs({ pagination: this.pagination, sort: this.sort, search: this.search })
.subscribe({ .subscribe({

View file

@ -45,7 +45,14 @@
></my-action-dropdown> ></my-action-dropdown>
</td> </td>
<td>{{ registrationToken.registrationToken }}</td> <td>
{{ registrationToken.registrationToken }}
<my-copy-button
[value]="registrationToken.registrationToken" i18n-notification notification="Registration token copied"
i18n-title title="Copy registration token"
></my-copy-button>
</td>
<td>{{ registrationToken.createdAt | date: 'short' }}</td> <td>{{ registrationToken.createdAt | date: 'short' }}</td>

View file

@ -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;
}
}

View file

@ -7,6 +7,7 @@ import { RunnerService } from '../runner.service'
@Component({ @Component({
selector: 'my-runner-registration-token-list', selector: 'my-runner-registration-token-list',
styleUrls: [ './runner-registration-token-list.component.scss' ],
templateUrl: './runner-registration-token-list.component.html' templateUrl: './runner-registration-token-list.component.html'
}) })
export class RunnerRegistrationTokenListComponent extends RestTable <RunnerRegistrationToken> implements OnInit { export class RunnerRegistrationTokenListComponent extends RestTable <RunnerRegistrationToken> implements OnInit {

View file

@ -6,7 +6,7 @@ import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService, ServerService } from '@app/core' import { RestExtractor, RestPagination, RestService, ServerService } from '@app/core'
import { arrayify, peertubeTranslate } from '@shared/core-utils' import { arrayify, peertubeTranslate } from '@shared/core-utils'
import { ResultList } from '@shared/models/common' 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' import { environment } from '../../../../environments/environment'
export type RunnerJobFormatted = RunnerJob & { export type RunnerJobFormatted = RunnerJob & {
@ -60,7 +60,9 @@ export class RunnerService {
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
if (search) params = params.append('search', search) if (search) {
params = this.buildParamsFromSearch(search, params)
}
return forkJoin([ return forkJoin([
this.authHttp.get<ResultList<RunnerJobAdmin>>(RunnerService.BASE_RUNNER_URL + '/jobs', { params }), this.authHttp.get<ResultList<RunnerJobAdmin>>(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[]) { cancelJobs (jobsArg: RunnerJob | RunnerJob[]) {
const jobs = arrayify(jobsArg) 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: { listRunners (options: {

View file

@ -64,11 +64,11 @@
<div class="actor-handle"> <div class="actor-handle">
<span>@{{ videoChannel.nameWithHost }}</span> <span>@{{ videoChannel.nameWithHost }}</span>
<button [cdkCopyToClipboard]="videoChannel.nameWithHostForced" (click)="activateCopiedMessage()"
class="btn btn-outline-secondary btn-sm copy-button" title="Copy channel handle" i18n-title <my-copy-button
> [value]="videoChannel.nameWithHostForced" i18n-notification notification="Handle copied"
<my-global-icon iconName="copy"></my-global-icon> title="Copy channel handle" i18n-title
</button> ></my-copy-button>
</div> </div>
<div class="actor-counters"> <div class="actor-counters">

View file

@ -152,14 +152,8 @@
display: none; display: none;
} }
.copy-button { my-copy-button {
@include margin-left(3px); @include margin-left(3px);
border: 0;
my-global-icon {
width: 15px;
}
} }
@media screen and (max-width: 1400px) { @media screen and (max-width: 1400px) {

View file

@ -120,10 +120,6 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
return this.isOwner() || this.authService.getUser().hasRight(UserRight.MANAGE_ANY_VIDEO_CHANNEL) return this.isOwner() || this.authService.getUser().hasRight(UserRight.MANAGE_ANY_VIDEO_CHANNEL)
} }
activateCopiedMessage () {
this.notifier.success($localize`Username copied`)
}
hasShowMoreDescription () { hasShowMoreDescription () {
return !this.channelDescriptionExpanded && this.channelDescriptionHTML.length > 100 return !this.channelDescriptionExpanded && this.channelDescriptionHTML.length > 100
} }

View file

@ -7,16 +7,18 @@ import { RestPagination } from './rest-pagination'
const debugLogger = debug('peertube:rest') const debugLogger = debug('peertube:rest')
type ParseQueryHandlerResult = string | number | boolean | string[] | number[] | boolean[]
interface QueryStringFilterPrefixes { interface QueryStringFilterPrefixes {
[key: string]: { [key: string]: {
prefix: string prefix: string
handler?: (v: string) => string | number | boolean handler?: (v: string) => ParseQueryHandlerResult
multiple?: boolean multiple?: boolean
isBoolean?: boolean isBoolean?: boolean
} }
} }
type ParseQueryStringFilters <K extends keyof any> = Partial<Record<K, string | number | boolean | (string | number | boolean)[]>> type ParseQueryStringFilters <K extends keyof any> = Partial<Record<K, ParseQueryHandlerResult | ParseQueryHandlerResult[]>>
type ParseQueryStringFiltersResult <K extends keyof any> = ParseQueryStringFilters<K> & { search?: string } type ParseQueryStringFiltersResult <K extends keyof any> = ParseQueryStringFilters<K> & { search?: string }
@Injectable() @Injectable()

View file

@ -33,6 +33,5 @@ my-global-icon {
div[role=menu] { div[role=menu] {
max-height: 50vh; max-height: 50vh;
min-height: 200px;
overflow: auto; overflow: auto;
} }

View file

@ -11,13 +11,12 @@
<my-global-icon *ngIf="!show" iconName="eye-close"></my-global-icon> <my-global-icon *ngIf="!show" iconName="eye-close"></my-global-icon>
</button> </button>
<button <my-copy-button
*ngIf="withCopy" [cdkCopyToClipboard]="input.value" (click)="activateCopiedMessage()" type="button" *ngIf="withCopy" [value]="input.value" i18n-notification notification="Copied"
class="btn btn-outline-secondary text-uppercase" i18n-title title="Copy" [isInputGroup]="true" i18n
> >
<my-global-icon iconName="copy"></my-global-icon> COPY
<span class="copy-text">Copy</span> </my-copy-button>
</button>
</div> </div>
<div *ngIf="formError" class="form-error">{{ formError }}</div> <div *ngIf="formError" class="form-error">{{ formError }}</div>

View file

@ -46,10 +46,6 @@ export class InputTextComponent implements ControlValueAccessor {
this.show = !this.show this.show = !this.show
} }
activateCopiedMessage () {
this.notifier.success($localize`Copied`)
}
propagateChange = (_: any) => { /* empty */ } propagateChange = (_: any) => { /* empty */ }
writeValue (value: string) { writeValue (value: string) {

View file

@ -0,0 +1,9 @@
<button
class="btn btn-outline-secondary btn-sm copy-button"
[cdkCopyToClipboard]="value" (click)="activateCopiedMessage()"
[title]="title" [ngClass]="{ 'is-input-group': isInputGroup }"
>
<my-global-icon iconName="copy"></my-global-icon>
<ng-content></ng-content>
</button>

View file

@ -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;
}

View file

@ -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)
}
}

View file

@ -1,4 +1,5 @@
export * from './action-dropdown.component' export * from './action-dropdown.component'
export * from './button.component' export * from './button.component'
export * from './copy-button.component'
export * from './delete-button.component' export * from './delete-button.component'
export * from './edit-button.component' export * from './edit-button.component'

View file

@ -31,7 +31,7 @@ import {
PeerTubeTemplateDirective PeerTubeTemplateDirective
} from './angular' } from './angular'
import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 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 { CustomPageService } from './custom-page'
import { DateToggleComponent } from './date' import { DateToggleComponent } from './date'
import { FeedComponent } from './feeds' import { FeedComponent } from './feeds'
@ -100,6 +100,7 @@ import { VideoChannelService } from './video-channel'
ActionDropdownComponent, ActionDropdownComponent,
ButtonComponent, ButtonComponent,
CopyButtonComponent,
DeleteButtonComponent, DeleteButtonComponent,
EditButtonComponent, EditButtonComponent,
@ -162,6 +163,7 @@ import { VideoChannelService } from './video-channel'
ActionDropdownComponent, ActionDropdownComponent,
ButtonComponent, ButtonComponent,
CopyButtonComponent,
DeleteButtonComponent, DeleteButtonComponent,
EditButtonComponent, EditButtonComponent,

View file

@ -5,7 +5,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { generateRunnerJobToken } from '@server/helpers/token-generator' import { generateRunnerJobToken } from '@server/helpers/token-generator'
import { MIMETYPES } from '@server/initializers/constants' import { MIMETYPES } from '@server/initializers/constants'
import { sequelizeTypescript } from '@server/initializers/database' import { sequelizeTypescript } from '@server/initializers/database'
import { getRunnerJobHandlerClass, updateLastRunnerContact } from '@server/lib/runners' import { getRunnerJobHandlerClass, runnerJobCanBeCancelled, updateLastRunnerContact } from '@server/lib/runners'
import { import {
apiRateLimiter, apiRateLimiter,
asyncMiddleware, asyncMiddleware,
@ -23,6 +23,7 @@ import {
errorRunnerJobValidator, errorRunnerJobValidator,
getRunnerFromTokenValidator, getRunnerFromTokenValidator,
jobOfRunnerGetValidatorFactory, jobOfRunnerGetValidatorFactory,
listRunnerJobsValidator,
runnerJobGetValidator, runnerJobGetValidator,
successRunnerJobValidator, successRunnerJobValidator,
updateRunnerJobValidator updateRunnerJobValidator
@ -131,9 +132,17 @@ runnerJobsRouter.get('/jobs',
runnerJobsSortValidator, runnerJobsSortValidator,
setDefaultSort, setDefaultSort,
setDefaultPagination, setDefaultPagination,
listRunnerJobsValidator,
asyncMiddleware(listRunnerJobs) asyncMiddleware(listRunnerJobs)
) )
runnerJobsRouter.delete('/jobs/:jobUUID',
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
asyncMiddleware(runnerJobGetValidator),
asyncMiddleware(deleteRunnerJob)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -374,6 +383,21 @@ async function cancelRunnerJob (req: express.Request, res: express.Response) {
return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 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) { async function listRunnerJobs (req: express.Request, res: express.Response) {
const query: ListRunnerJobsQuery = req.query const query: ListRunnerJobsQuery = req.query
@ -381,7 +405,8 @@ async function listRunnerJobs (req: express.Request, res: express.Response) {
start: query.start, start: query.start,
count: query.count, count: query.count,
sort: query.sort, sort: query.sort,
search: query.search search: query.search,
stateOneOf: query.stateOneOf
}) })
return res.json({ return res.json({

View file

@ -1,6 +1,6 @@
import { UploadFilesForCheck } from 'express' import { UploadFilesForCheck } from 'express'
import validator from 'validator' import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants'
import { import {
LiveRTMPHLSTranscodingSuccess, LiveRTMPHLSTranscodingSuccess,
RunnerJobSuccessPayload, RunnerJobSuccessPayload,
@ -11,7 +11,7 @@ import {
VODHLSTranscodingSuccess, VODHLSTranscodingSuccess,
VODWebVideoTranscodingSuccess VODWebVideoTranscodingSuccess
} from '@shared/models' } 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 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) 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 { export {
@ -65,7 +73,9 @@ export {
isRunnerJobTokenValid, isRunnerJobTokenValid,
isRunnerJobErrorMessageValid, isRunnerJobErrorMessageValid,
isRunnerJobProgressValid, isRunnerJobProgressValid,
isRunnerJobAbortReasonValid isRunnerJobAbortReasonValid,
isRunnerJobArrayOfStateValid,
isRunnerJobStateValid
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -2,8 +2,9 @@ import express from 'express'
import { retryTransactionWrapper } from '@server/helpers/database-utils' import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger' import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { sequelizeTypescript } from '@server/initializers/database' 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 { RUNNER_JOBS } from '@server/initializers/constants'
import { RunnerJobState } from '@shared/models'
const lTags = loggerTagsFactory('runner') const lTags = loggerTagsFactory('runner')
@ -32,6 +33,17 @@ function updateLastRunnerContact (req: express.Request, runner: MRunner) {
.finally(() => updatingRunner.delete(runner.id)) .finally(() => updatingRunner.delete(runner.id))
} }
export { function runnerJobCanBeCancelled (runnerJob: MRunnerJob) {
updateLastRunnerContact const allowedStates = new Set<RunnerJobState>([
RunnerJobState.PENDING,
RunnerJobState.PROCESSING,
RunnerJobState.WAITING_FOR_PARENT_JOB
])
return allowedStates.has(runnerJob.state)
}
export {
updateLastRunnerContact,
runnerJobCanBeCancelled
} }

View file

@ -1,8 +1,9 @@
import express from 'express' import express from 'express'
import { body, param } from 'express-validator' import { body, param, query } from 'express-validator'
import { isUUIDValid } from '@server/helpers/custom-validators/misc' import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc'
import { import {
isRunnerJobAbortReasonValid, isRunnerJobAbortReasonValid,
isRunnerJobArrayOfStateValid,
isRunnerJobErrorMessageValid, isRunnerJobErrorMessageValid,
isRunnerJobProgressValid, isRunnerJobProgressValid,
isRunnerJobSuccessPayloadValid, isRunnerJobSuccessPayloadValid,
@ -12,7 +13,9 @@ import {
import { isRunnerTokenValid } from '@server/helpers/custom-validators/runners/runners' import { isRunnerTokenValid } from '@server/helpers/custom-validators/runners/runners'
import { cleanUpReqFiles } from '@server/helpers/express-utils' import { cleanUpReqFiles } from '@server/helpers/express-utils'
import { LiveManager } from '@server/lib/live' import { LiveManager } from '@server/lib/live'
import { runnerJobCanBeCancelled } from '@server/lib/runners'
import { RunnerJobModel } from '@server/models/runner/runner-job' import { RunnerJobModel } from '@server/models/runner/runner-job'
import { arrayify } from '@shared/core-utils'
import { import {
HttpStatusCode, HttpStatusCode,
RunnerJobLiveRTMPHLSTranscodingPrivatePayload, RunnerJobLiveRTMPHLSTranscodingPrivatePayload,
@ -119,13 +122,7 @@ export const cancelRunnerJobValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
const runnerJob = res.locals.runnerJob const runnerJob = res.locals.runnerJob
const allowedStates = new Set<RunnerJobState>([ if (runnerJobCanBeCancelled(runnerJob) !== true) {
RunnerJobState.PENDING,
RunnerJobState.PROCESSING,
RunnerJobState.WAITING_FOR_PARENT_JOB
])
if (allowedStates.has(runnerJob.state) !== true) {
return res.fail({ return res.fail({
status: HttpStatusCode.BAD_REQUEST_400, status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot cancel this job that is not in "pending", "processing" or "waiting for parent job" state', 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 = [ export const runnerJobGetValidator = [
param('jobUUID').custom(isUUIDValid), param('jobUUID').custom(isUUIDValid),

View file

@ -1,4 +1,4 @@
import { FindOptions, Op, Transaction } from 'sequelize' import { Op, Transaction } from 'sequelize'
import { import {
AllowNull, AllowNull,
BelongsTo, BelongsTo,
@ -13,7 +13,7 @@ import {
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } 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 { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants'
import { MRunnerJob, MRunnerJobRunner, MRunnerJobRunnerParent } from '@server/types/models/runners' import { MRunnerJob, MRunnerJobRunner, MRunnerJobRunnerParent } from '@server/types/models/runners'
import { RunnerJob, RunnerJobAdmin, RunnerJobPayload, RunnerJobPrivatePayload, RunnerJobState, RunnerJobType } from '@shared/models' import { RunnerJob, RunnerJobAdmin, RunnerJobPayload, RunnerJobPrivatePayload, RunnerJobState, RunnerJobType } from '@shared/models'
@ -227,28 +227,38 @@ export class RunnerJobModel extends Model<Partial<AttributesOnly<RunnerJobModel>
count: number count: number
sort: string sort: string
search?: 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, offset: start,
limit: count, limit: count,
order: getSort(sort) order: getSort(sort),
where: []
} }
if (search) { if (search) {
if (isUUIDValid(search)) { if (isUUIDValid(search)) {
query.where = { uuid: search } query.where.push({ uuid: search })
} else { } else {
query.where = { query.where.push({
[Op.or]: [ [Op.or]: [
searchAttribute(search, 'type'), searchAttribute(search, 'type'),
searchAttribute(search, '$Runner.name$') searchAttribute(search, '$Runner.name$')
] ]
} })
} }
} }
if (isArray(stateOneOf) && stateOneOf.length !== 0) {
query.where.push({
state: {
[Op.in]: stateOneOf
}
})
}
return Promise.all([ return Promise.all([
RunnerJobModel.scope([ ScopeNames.WITH_RUNNER ]).count(query), RunnerJobModel.scope([ ScopeNames.WITH_RUNNER ]).count(query),
RunnerJobModel.scope([ ScopeNames.WITH_RUNNER, ScopeNames.WITH_PARENT ]).findAll<MRunnerJobRunnerParent>(query) RunnerJobModel.scope([ ScopeNames.WITH_RUNNER, ScopeNames.WITH_PARENT ]).findAll<MRunnerJobRunnerParent>(query)

View file

@ -1,14 +1,14 @@
import { basename } from 'path'
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { basename } from 'path'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
import { import {
HttpStatusCode, HttpStatusCode,
isVideoStudioTaskIntro, isVideoStudioTaskIntro,
RunnerJob, RunnerJob,
RunnerJobState, RunnerJobState,
RunnerJobStudioTranscodingPayload,
RunnerJobSuccessPayload, RunnerJobSuccessPayload,
RunnerJobUpdatePayload, RunnerJobUpdatePayload,
RunnerJobStudioTranscodingPayload,
VideoPrivacy, VideoPrivacy,
VideoStudioTaskIntro VideoStudioTaskIntro
} from '@shared/models' } from '@shared/models'
@ -236,6 +236,10 @@ describe('Test managing runners', function () {
await checkBadSortPagination(server.url, path, server.accessToken) 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 () { it('Should succeed to list with the correct params', async function () {
await server.runners.list({ start: 0, count: 5, sort: '-createdAt' }) 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) await checkBadSortPagination(server.url, path, server.accessToken)
}) })
it('Should succeed to list with the correct params', async function () { it('Should fail with an invalid state', async function () {
await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt' }) 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 })
}) })
}) })

View file

@ -339,6 +339,30 @@ describe('Test runner common actions', function () {
expect(data).to.not.have.lengthOf(0) expect(data).to.not.have.lengthOf(0)
expect(total).to.not.equal(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 () { describe('Stalled jobs', function () {
it('Should abort stalled jobs', async function () { it('Should abort stalled jobs', async function () {

View file

@ -1,6 +1,9 @@
import { RunnerJobState } from './runner-job-state.model'
export interface ListRunnerJobsQuery { export interface ListRunnerJobsQuery {
start?: number start?: number
count?: number count?: number
sort?: string sort?: string
search?: string search?: string
stateOneOf?: RunnerJobState[]
} }

View file

@ -8,6 +8,7 @@ import {
isHLSTranscodingPayloadSuccess, isHLSTranscodingPayloadSuccess,
isLiveRTMPHLSTranscodingUpdatePayload, isLiveRTMPHLSTranscodingUpdatePayload,
isWebVideoOrAudioMergeTranscodingPayloadSuccess, isWebVideoOrAudioMergeTranscodingPayloadSuccess,
ListRunnerJobsQuery,
RequestRunnerJobBody, RequestRunnerJobBody,
RequestRunnerJobResult, RequestRunnerJobResult,
ResultList, ResultList,
@ -27,19 +28,14 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared'
export class RunnerJobsCommand extends AbstractCommand { export class RunnerJobsCommand extends AbstractCommand {
list (options: OverrideCommandOptions & { list (options: OverrideCommandOptions & ListRunnerJobsQuery = {}) {
start?: number
count?: number
sort?: string
search?: string
} = {}) {
const path = '/api/v1/runners/jobs' const path = '/api/v1/runners/jobs'
return this.getRequestBody<ResultList<RunnerJobAdmin>>({ return this.getRequestBody<ResultList<RunnerJobAdmin>>({
...options, ...options,
path, path,
query: pick(options, [ 'start', 'count', 'sort', 'search' ]), query: pick(options, [ 'start', 'count', 'sort', 'search', 'stateOneOf' ]),
implicitToken: true, implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200 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) { request (options: OverrideCommandOptions & RequestRunnerJobBody) {

View file

@ -6088,6 +6088,21 @@ paths:
'204': '204':
description: successful operation 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: /api/v1/runners/jobs:
get: get:
summary: List jobs summary: List jobs
@ -6101,6 +6116,13 @@ paths:
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/runnerJobSort' - $ref: '#/components/parameters/runnerJobSort'
- $ref: '#/components/parameters/search' - $ref: '#/components/parameters/search'
- name: stateOneOf
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/RunnerJobState'
responses: responses:
'200': '200':
description: successful operation description: successful operation