]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Improve runner management
authorChocobozzz <me@florianbigard.com>
Thu, 27 Jul 2023 09:44:31 +0000 (11:44 +0200)
committerChocobozzz <me@florianbigard.com>
Thu, 27 Jul 2023 12:17:12 +0000 (14:17 +0200)
 * 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

31 files changed:
client/src/app/+accounts/accounts.component.html
client/src/app/+accounts/accounts.component.scss
client/src/app/+accounts/accounts.component.ts
client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.html
client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.html
client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.scss [new file with mode: 0644]
client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.ts
client/src/app/+admin/system/runners/runner.service.ts
client/src/app/+video-channels/video-channels.component.html
client/src/app/+video-channels/video-channels.component.scss
client/src/app/+video-channels/video-channels.component.ts
client/src/app/core/rest/rest.service.ts
client/src/app/shared/shared-forms/advanced-input-filter.component.scss
client/src/app/shared/shared-forms/input-text.component.html
client/src/app/shared/shared-forms/input-text.component.ts
client/src/app/shared/shared-main/buttons/copy-button.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/copy-button.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/copy-button.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/index.ts
client/src/app/shared/shared-main/shared-main.module.ts
server/controllers/api/runners/jobs.ts
server/helpers/custom-validators/runners/jobs.ts
server/lib/runners/runner.ts
server/middlewares/validators/runners/jobs.ts
server/models/runner/runner-job.ts
server/tests/api/check-params/runners.ts
server/tests/api/runners/runner-common.ts
shared/models/runners/list-runner-jobs-query.model.ts
shared/server-commands/runners/runner-jobs-command.ts
support/doc/api/openapi.yaml

index 66d10813479f9d8fb23cd0cba23584dd8eadbeff..1283105e90dcc70f2c58a5eefce6275f05b2b211 100644 (file)
 
             <div class="actor-handle">
               <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-global-icon iconName="copy"></my-global-icon>
-              </button>
+
+              <my-copy-button
+                [value]="account.nameWithHostForced" i18n-notification notification="Username copied"
+                title="Copy account handle" i18n-title
+              ></my-copy-button>
             </div>
 
             <div class="actor-counters">
index 56b952b657960bb31fad19b39a575166e6de8f34..aadd6f5c02b22b5cde4104a380e30048004e7e3e 100644 (file)
   }
 }
 
-.copy-button {
+my-copy-button {
   @include margin-left(3px);
-
-  border: 0;
-
-  my-global-icon {
-    width: 15px;
-  }
 }
 
 .account-info {
index 0033fbf59f37c71bbf2fc563fcbb5f03384fd458..6d912e32567118d523962ddef3495cb443410650 100644 (file)
@@ -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 }
 
index a759e91865d74be784e3994dec473a661bfe11e8..1a40da0c0fd29d8f4ea118037ddd3733e1a7c67d 100644 (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="progress">Progress <p-sortIcon field="progress"></p-sortIcon></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>
   </ng-template>
 
@@ -47,7 +47,7 @@
       </div>
 
       <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>
       </div>
@@ -73,7 +73,9 @@
 
       <td>{{ runnerJob.uuid }}</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>
index 8994c1d00ce754c78d4e92b11b65320f687132b8..2670eac86ca79cbd07b6373eae3baf273658fad3 100644 (file)
@@ -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 <RunnerJob> implements OnI
   actions: 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 (
     private runnerService: RunnerService,
     private notifier: Notifier,
@@ -36,6 +61,12 @@ export class RunnerJobListComponent extends RestTable <RunnerJob> 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 <RunnerJob> 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 <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 () {
     this.runnerService.listRunnerJobs({ pagination: this.pagination, sort: this.sort, search: this.search })
       .subscribe({
index 3e5cea8813bed02b075eece705ca6692c7d7016d..4ff2c3b4ca1e997040017b9437ffa8fbd8f2484a 100644 (file)
         ></my-action-dropdown>
       </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>
 
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 (file)
index 0000000..1cfb2e6
--- /dev/null
@@ -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;
+  }
+}
index f03aab189d6fd3b2ff4d2ff8bc9198b78701a3cf..77908a2e198f0a481ec991c6b537708de6c746bf 100644 (file)
@@ -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 <RunnerRegistrationToken> implements OnInit {
index 392ec82bcae79b472c0530a8ad4395e1eabdc361..3ab36c4ff692f712112c63eade60eedc825d6109 100644 (file)
@@ -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<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[]) {
     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: {
index fff160f2e9e9432e35d47432da6e90819b41dfd4..228cc4eddaff8f660228109306cc088cdf94a60d 100644 (file)
 
             <div class="actor-handle">
               <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-global-icon iconName="copy"></my-global-icon>
-              </button>
+
+              <my-copy-button
+                [value]="videoChannel.nameWithHostForced" i18n-notification notification="Handle copied"
+                title="Copy channel handle" i18n-title
+              ></my-copy-button>
             </div>
 
             <div class="actor-counters">
index aba266fcca328517e115e8e54b877e62b97b96e7..182e8d84576b81e5e4988b9437f489278e154ccb 100644 (file)
   display: none;
 }
 
-.copy-button {
+my-copy-button {
   @include margin-left(3px);
-
-  border: 0;
-
-  my-global-icon {
-    width: 15px;
-  }
 }
 
 @media screen and (max-width: 1400px) {
index afbf960325fa6a5a83556c3dd694b9f05c4af49d..f5bea66ec14ccf12455f11badbf09268432c801d 100644 (file)
@@ -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
   }
index d8b5ffb1851755aee06a8373a2c035c4cc5bc1d9..f07afb7e8b290fae598c38582df26ac72d07e833 100644 (file)
@@ -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 <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 }
 
 @Injectable()
index 4efbeb85da9cbbd8e229b436cdf08c80be0de9e8..f5438ffdbca0cf6f284d88c66629a6723564af6d 100644 (file)
@@ -33,6 +33,5 @@ my-global-icon {
 
 div[role=menu] {
   max-height: 50vh;
-  min-height: 200px;
   overflow: auto;
 }
index abb53a085638cf0301fb30d87081e362726bfdc3..4747e2f8f152d9c246e43064779bd06f352f5d58 100644 (file)
     <my-global-icon *ngIf="!show" iconName="eye-close"></my-global-icon>
   </button>
 
-  <button
-    *ngIf="withCopy" [cdkCopyToClipboard]="input.value" (click)="activateCopiedMessage()" type="button"
-    class="btn btn-outline-secondary text-uppercase" i18n-title title="Copy"
+  <my-copy-button
+    *ngIf="withCopy" [value]="input.value" i18n-notification notification="Copied"
+    [isInputGroup]="true" i18n
   >
-    <my-global-icon iconName="copy"></my-global-icon>
-    <span class="copy-text">Copy</span>
-  </button>
+    COPY
+  </my-copy-button>
 </div>
 
 <div *ngIf="formError" class="form-error">{{ formError }}</div>
index aa4a1cba8347cddf1ead4174a258854c96863928..be03f25b90b1fc6f4fbc1d58d9ead2651cce9087 100644 (file)
@@ -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 (file)
index 0000000..a99c0a9
--- /dev/null
@@ -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>
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 (file)
index 0000000..7e37204
--- /dev/null
@@ -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 (file)
index 0000000..aac9ab8
--- /dev/null
@@ -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)
+  }
+}
index 775a47a39fed31ae90c4c3e2c619732fa6994a16..75efbdea3fa41d71e4f44de092b51db7da4d271c 100644 (file)
@@ -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'
index 4802774501eeaad02aed6f66638c644c7f3bb07f..243394bda7895ffe969af76722c4939d8b4e9bb6 100644 (file)
@@ -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,
 
index be5911b53d726e30a4fbf6865f6df9148c3b6721..e9e2ddf499d66e8299bd54a582658a32a7cdac9c 100644 (file)
@@ -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({
index 725a7658f58eff3942ea8d8b5454cab191efeec2..6349e79baa3b9eadeed8270df3f70571ea93376d 100644 (file)
@@ -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
 }
 
 // ---------------------------------------------------------------------------
index 921cae6f22975df5e915f2d74e3fdc2cf77fd1fe..947fdb3f034080376731d4612cbc24af41d74da2 100644 (file)
@@ -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))
 }
 
+function runnerJobCanBeCancelled (runnerJob: MRunnerJob) {
+  const allowedStates = new Set<RunnerJobState>([
+    RunnerJobState.PENDING,
+    RunnerJobState.PROCESSING,
+    RunnerJobState.WAITING_FOR_PARENT_JOB
+  ])
+
+  return allowedStates.has(runnerJob.state)
+}
+
 export {
-  updateLastRunnerContact
+  updateLastRunnerContact,
+  runnerJobCanBeCancelled
 }
index 384b209ba580d0254c8299000b75ac68e30d551a..62f9340a5782787d24e1f0284e61d867e49fc250 100644 (file)
@@ -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>([
-      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),
 
index add6f9a43a01102042dc9fc32394c02b430e5608..f2ffd6a84405d044f190fa43b5f614f83f350524 100644 (file)
@@ -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<Partial<AttributesOnly<RunnerJobModel>
     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<MRunnerJobRunnerParent>(query)
index 9112ff7166d7e4ee70e115b065f1027b960de81a..7f9a0cd32f15bfb7e12a87977703b80b36305d1d 100644 (file)
@@ -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 })
       })
     })
 
index 34a51abe7abb20e7c0ffcc61dacb9e9d66d8e2ea..7fed75f40c5d7f4b2d70ecbbc125aad0a7cba4af 100644 (file)
@@ -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 () {
index a5b62c55df144dd5c603b13a1406c1c0be576f5b..ef19b31faae1cf27c75a922110c153c45b7c44c8 100644 (file)
@@ -1,6 +1,9 @@
+import { RunnerJobState } from './runner-job-state.model'
+
 export interface ListRunnerJobsQuery {
   start?: number
   count?: number
   sort?: string
   search?: string
+  stateOneOf?: RunnerJobState[]
 }
index 26dbef77ae0982a18d19a765308db459d25cfdc3..0a0ffb5d32a39984a4183b835c77b073935c22bc 100644 (file)
@@ -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<ResultList<RunnerJobAdmin>>({
       ...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) {
index 654bd7461fb02aa203606d32ac034033f8da414f..44daecf852da816ddc1772be662fe6508935d06e 100644 (file)
@@ -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