diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts index 630bfe253..d4d912c40 100644 --- a/client/src/app/+admin/admin.component.ts +++ b/client/src/app/+admin/admin.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core' -import { AuthService, ScreenService } from '@app/core' +import { AuthService, ScreenService, ServerService } from '@app/core' import { ListOverflowItem } from '@app/shared/shared-main' import { TopMenuDropdownParam } from '@app/shared/shared-main/misc/top-menu-dropdown.component' import { UserRight } from '@shared/models' @@ -14,7 +14,8 @@ export class AdminComponent implements OnInit { constructor ( private auth: AuthService, - private screen: ScreenService + private screen: ScreenService, + private server: ServerService ) { } get isBroadcastMessageDisplayed () { @@ -22,6 +23,14 @@ export class AdminComponent implements OnInit { } ngOnInit () { + this.server.configReloaded.subscribe(() => this.buildMenu()) + + this.buildMenu() + } + + private buildMenu () { + this.menuEntries = [] + this.buildOverviewItems() this.buildFederationItems() this.buildModerationItems() @@ -157,9 +166,23 @@ export class AdminComponent implements OnInit { children: [] } + if (this.isRemoteRunnersEnabled() && this.hasRunnersRight()) { + systemItems.children.push({ + label: $localize`Remote runners`, + iconName: 'codesandbox', + routerLink: '/admin/system/runners/runners-list' + }) + + systemItems.children.push({ + label: $localize`Runner jobs`, + iconName: 'globe', + routerLink: '/admin/system/runners/jobs-list' + }) + } + if (this.hasJobsRight()) { systemItems.children.push({ - label: $localize`Jobs`, + label: $localize`Local jobs`, iconName: 'circle-tick', routerLink: '/admin/system/jobs' }) @@ -226,6 +249,10 @@ export class AdminComponent implements OnInit { return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS) } + private hasRunnersRight () { + return this.auth.getUser().hasRight(UserRight.MANAGE_RUNNERS) + } + private hasDebugRight () { return this.auth.getUser().hasRight(UserRight.MANAGE_DEBUG) } @@ -241,4 +268,10 @@ export class AdminComponent implements OnInit { private hasRegistrationsRight () { return this.auth.getUser().hasRight(UserRight.MANAGE_REGISTRATIONS) } + + private isRemoteRunnersEnabled () { + const config = this.server.getHTMLConfig() + + return config.transcoding.remoteRunners.enabled || config.live.transcoding.remoteRunners.enabled + } } diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index 891ff4ed1..006cb025a 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts @@ -56,9 +56,17 @@ import { PluginShowInstalledComponent } from './plugins' import { SharedAdminModule } from './shared' -import { JobService, LogsComponent, LogsService } from './system' +import { + JobService, + LogsComponent, + LogsService, + RunnerJobListComponent, + RunnerListComponent, + RunnerRegistrationTokenListComponent, + RunnerService +} from './system' import { DebugComponent, DebugService } from './system/debug' -import { JobsComponent } from './system/jobs/jobs.component' +import { JobsComponent } from './system/jobs' @NgModule({ imports: [ @@ -125,7 +133,11 @@ import { JobsComponent } from './system/jobs/jobs.component' EditHomepageComponent, RegistrationListComponent, - ProcessRegistrationModalComponent + ProcessRegistrationModalComponent, + + RunnerRegistrationTokenListComponent, + RunnerListComponent, + RunnerJobListComponent ], exports: [ @@ -140,7 +152,8 @@ import { JobsComponent } from './system/jobs/jobs.component' PluginApiService, EditConfigurationService, VideoAdminService, - AdminRegistrationService + AdminRegistrationService, + RunnerService ] }) export class AdminModule { } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 0526ed8f1..335aedb67 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -190,6 +190,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { }, webtorrent: { enabled: null + }, + remoteRunners: { + enabled: null } }, live: { @@ -208,7 +211,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { threads: TRANSCODING_THREADS_VALIDATOR, profile: null, resolutions: {}, - alwaysTranscodeOriginalResolution: null + alwaysTranscodeOriginalResolution: null, + remoteRunners: { + enabled: null + } } }, videoStudio: { diff --git a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html index c90c34c80..34ce8efa6 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html @@ -110,6 +110,20 @@ +
+ + + + Use remote runners to process live transcoding. + Remote runners has to register on your instance first. + + + +
+
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html index de9e7253e..c11f560dd 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html @@ -37,6 +37,20 @@ +
+ + + + Use remote runners to process VOD transcoding. + Remote runners has to register on your instance first. + + + +
+
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html index eca79be71..1605190f6 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.html +++ b/client/src/app/+admin/follows/following-list/following-list.component.html @@ -5,7 +5,7 @@ + + + Runner jobs + + + + + Remote runners + + + + + + + + + UUID + Type + State + Priority + Progress + Runner + Created + + + + +
+
+ + + +
+
+
+ + + + + + + + + + + + {{ runnerJob.uuid }} + {{ runnerJob.type }} + {{ runnerJob.state.label }} + {{ runnerJob.priority }} + {{ runnerJob.progress }} + {{ runnerJob.runner?.name }} + {{ runnerJob.createdAt | date: 'short' }} + + + + + + +
+ Parent job: {{ runnerJob.parent?.uuid || '-' }}
+ Processed on {{ (runnerJob.startedAt || '-') }}
+ Finished on {{ (runnerJob.finishedAt || '-') }}
+
+ +
+ Payload: +
{{ runnerJob.payload }}
+
+ +
+ Private payload: +
{{ runnerJob.privatePayload }}
+
+ +
{{ runnerJob.error }}
+ + +
+ + + + +
+ No runner jobs found. +
+ + +
+
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 new file mode 100644 index 000000000..ea889f0f7 --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts @@ -0,0 +1,76 @@ +import { SortMeta } from 'primeng/api' +import { Component, OnInit } from '@angular/core' +import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' +import { DropdownAction } from '@app/shared/shared-main' +import { RunnerJob } from '@shared/models' +import { RunnerJobFormatted, RunnerService } from '../runner.service' + +@Component({ + selector: 'my-runner-job-list', + templateUrl: './runner-job-list.component.html' +}) +export class RunnerJobListComponent extends RestTable implements OnInit { + runnerJobs: RunnerJobFormatted[] = [] + totalRecords = 0 + + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + actions: DropdownAction[][] = [] + + constructor ( + private runnerService: RunnerService, + private notifier: Notifier, + private confirmService: ConfirmService + ) { + super() + } + + ngOnInit () { + this.actions = [ + [ + { + label: $localize`Cancel this job`, + handler: job => this.cancelJob(job) + } + ] + ] + + this.initialize() + } + + getIdentifier () { + return 'RunnerJobListComponent' + } + + async cancelJob (job: RunnerJob) { + const res = await this.confirmService.confirm( + $localize`Do you really want to cancel this job? Children won't be processed.`, + $localize`Cancel job` + ) + + if (res === false) return + + this.runnerService.cancelJob(job) + .subscribe({ + next: () => { + this.reloadData() + this.notifier.success($localize`Job cancelled.`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + protected reloadDataInternal () { + this.runnerService.listRunnerJobs({ pagination: this.pagination, sort: this.sort, search: this.search }) + .subscribe({ + next: resultList => { + this.runnerJobs = resultList.data + this.totalRecords = resultList.total + }, + + error: err => this.notifier.error(err.message) + }) + } +} diff --git a/client/src/app/+admin/system/runners/runner-list/index.ts b/client/src/app/+admin/system/runners/runner-list/index.ts new file mode 100644 index 000000000..5c12bb6d6 --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-list/index.ts @@ -0,0 +1 @@ +export * from './runner-list.component' diff --git a/client/src/app/+admin/system/runners/runner-list/runner-list.component.html b/client/src/app/+admin/system/runners/runner-list/runner-list.component.html new file mode 100644 index 000000000..606eb9afd --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-list/runner-list.component.html @@ -0,0 +1,61 @@ +

+ + + Remote runners + + + + + Runner registration tokens + +

+ + + + + + Name + Description + IP + Last contact + Created + + + + + + + + + + {{ runner.name }} + + {{ runner.description }} + + {{ runner.ip }} + + {{ runner.lastContact | date: 'short' }} + + {{ runner.createdAt | date: 'short' }} + + + + + + +
+ No remote runners found. +
+ + +
+
diff --git a/client/src/app/+admin/system/runners/runner-list/runner-list.component.ts b/client/src/app/+admin/system/runners/runner-list/runner-list.component.ts new file mode 100644 index 000000000..7566f967e --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-list/runner-list.component.ts @@ -0,0 +1,76 @@ +import { SortMeta } from 'primeng/api' +import { Component, OnInit } from '@angular/core' +import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' +import { DropdownAction } from '@app/shared/shared-main' +import { Runner } from '@shared/models' +import { RunnerService } from '../runner.service' + +@Component({ + selector: 'my-runner-list', + templateUrl: './runner-list.component.html' +}) +export class RunnerListComponent extends RestTable implements OnInit { + runners: Runner[] = [] + totalRecords = 0 + + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + actions: DropdownAction[][] = [] + + constructor ( + private runnerService: RunnerService, + private notifier: Notifier, + private confirmService: ConfirmService + ) { + super() + } + + ngOnInit () { + this.actions = [ + [ + { + label: $localize`Remove`, + handler: runer => this.deleteRunner(runer) + } + ] + ] + + this.initialize() + } + + getIdentifier () { + return 'RunnerListComponent' + } + + async deleteRunner (runner: Runner) { + const res = await this.confirmService.confirm( + $localize`Do you really want to delete this runner? It won't be able to process jobs anymore.`, + $localize`Remove ${runner.name}` + ) + + if (res === false) return + + this.runnerService.deleteRunner(runner) + .subscribe({ + next: () => { + this.reloadData() + this.notifier.success($localize`Runner removed.`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + protected reloadDataInternal () { + this.runnerService.listRunners({ pagination: this.pagination, sort: this.sort }) + .subscribe({ + next: resultList => { + this.runners = resultList.data + this.totalRecords = resultList.total + }, + + error: err => this.notifier.error(err.message) + }) + } +} diff --git a/client/src/app/+admin/system/runners/runner-registration-token-list/index.ts b/client/src/app/+admin/system/runners/runner-registration-token-list/index.ts new file mode 100644 index 000000000..8e77978b3 --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-registration-token-list/index.ts @@ -0,0 +1 @@ +export * from './runner-registration-token-list.component' 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 new file mode 100644 index 000000000..2fd23e2fc --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.html @@ -0,0 +1,65 @@ +

+ + + Runner registration tokens + + + +

+ + + + + + Token + Created + Associated runners + + + + +
+
+ +
+
+
+ + + + + + + + {{ registrationToken.registrationToken }} + + {{ registrationToken.createdAt | date: 'short' }} + + {{ registrationToken.registeredRunnersCount }} + + + + + + +
+ No registration token found for remote runners. +
+ + +
+
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 new file mode 100644 index 000000000..f03aab189 --- /dev/null +++ b/client/src/app/+admin/system/runners/runner-registration-token-list/runner-registration-token-list.component.ts @@ -0,0 +1,88 @@ +import { SortMeta } from 'primeng/api' +import { Component, OnInit } from '@angular/core' +import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' +import { DropdownAction } from '@app/shared/shared-main' +import { RunnerRegistrationToken } from '@shared/models' +import { RunnerService } from '../runner.service' + +@Component({ + selector: 'my-runner-registration-token-list', + templateUrl: './runner-registration-token-list.component.html' +}) +export class RunnerRegistrationTokenListComponent extends RestTable implements OnInit { + registrationTokens: RunnerRegistrationToken[] = [] + totalRecords = 0 + + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + actions: DropdownAction[][] = [] + + constructor ( + private runnerService: RunnerService, + private notifier: Notifier, + private confirmService: ConfirmService + ) { + super() + } + + ngOnInit () { + this.actions = [ + [ + { + label: $localize`Remove this token`, + handler: token => this.removeToken(token) + } + ] + ] + + this.initialize() + } + + getIdentifier () { + return 'RunnerRegistrationTokenListComponent' + } + + generateToken () { + this.runnerService.generateToken() + .subscribe({ + next: () => { + this.reloadData() + this.notifier.success($localize`Registration token generated.`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + async removeToken (token: RunnerRegistrationToken) { + const res = await this.confirmService.confirm( + $localize`Do you really want to remove this registration token? All associated runners will also be removed.`, + $localize`Remove registration token` + ) + + if (res === false) return + + this.runnerService.removeToken(token) + .subscribe({ + next: () => { + this.reloadData() + this.notifier.success($localize`Registration token removed.`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + protected reloadDataInternal () { + this.runnerService.listRegistrationTokens({ pagination: this.pagination, sort: this.sort }) + .subscribe({ + next: resultList => { + this.registrationTokens = resultList.data + this.totalRecords = resultList.total + }, + + error: err => this.notifier.error(err.message) + }) + } +} diff --git a/client/src/app/+admin/system/runners/runner.service.ts b/client/src/app/+admin/system/runners/runner.service.ts new file mode 100644 index 000000000..05083318c --- /dev/null +++ b/client/src/app/+admin/system/runners/runner.service.ts @@ -0,0 +1,117 @@ + +import { SortMeta } from 'primeng/api' +import { catchError, forkJoin, map } from 'rxjs' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService, ServerService } from '@app/core' +import { peertubeTranslate } from '@shared/core-utils' +import { ResultList } from '@shared/models/common' +import { Runner, RunnerJob, RunnerJobAdmin, RunnerRegistrationToken } from '@shared/models/runners' +import { environment } from '../../../../environments/environment' + +export type RunnerJobFormatted = RunnerJob & { + payload: string + privatePayload: string +} + +@Injectable() +export class RunnerService { + private static BASE_RUNNER_URL = environment.apiUrl + '/api/v1/runners' + + constructor ( + private authHttp: HttpClient, + private server: ServerService, + private restService: RestService, + private restExtractor: RestExtractor + ) {} + + listRegistrationTokens (options: { + pagination: RestPagination + sort: SortMeta + }) { + const { pagination, sort } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp.get>(RunnerService.BASE_RUNNER_URL + '/registration-tokens', { params }) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + generateToken () { + return this.authHttp.post(RunnerService.BASE_RUNNER_URL + '/registration-tokens/generate', {}) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + removeToken (token: RunnerRegistrationToken) { + return this.authHttp.delete(RunnerService.BASE_RUNNER_URL + '/registration-tokens/' + token.id) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + // --------------------------------------------------------------------------- + + listRunnerJobs (options: { + pagination: RestPagination + sort: SortMeta + search?: string + }) { + const { pagination, sort, search } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + + return forkJoin([ + this.authHttp.get>(RunnerService.BASE_RUNNER_URL + '/jobs', { params }), + this.server.getServerLocale() + ]).pipe( + map(([ res, translations ]) => { + const newData = res.data.map(job => { + return { + ...job, + + state: { + id: job.state.id, + label: peertubeTranslate(job.state.label, translations) + }, + payload: JSON.stringify(job.payload, null, 2), + privatePayload: JSON.stringify(job.privatePayload, null, 2) + } as RunnerJobFormatted + }) + + return { + total: res.total, + data: newData + } + }), + map(res => this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'startedAt', 'finishedAt' ], 'precise')), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + cancelJob (job: RunnerJob) { + return this.authHttp.post(RunnerService.BASE_RUNNER_URL + '/jobs/' + job.uuid + '/cancel', {}) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + // --------------------------------------------------------------------------- + + listRunners (options: { + pagination: RestPagination + sort: SortMeta + }) { + const { pagination, sort } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp.get>(RunnerService.BASE_RUNNER_URL + '/', { params }) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + deleteRunner (runner: Runner) { + return this.authHttp.delete(RunnerService.BASE_RUNNER_URL + '/' + runner.id) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } +} diff --git a/client/src/app/+admin/system/runners/runners.routes.ts b/client/src/app/+admin/system/runners/runners.routes.ts new file mode 100644 index 000000000..fabe687d6 --- /dev/null +++ b/client/src/app/+admin/system/runners/runners.routes.ts @@ -0,0 +1,53 @@ +import { Routes } from '@angular/router' +import { UserRightGuard } from '@app/core' +import { UserRight } from '@shared/models' +import { RunnerJobListComponent } from './runner-job-list' +import { RunnerListComponent } from './runner-list' +import { RunnerRegistrationTokenListComponent } from './runner-registration-token-list' + +export const RunnersRoutes: Routes = [ + { + path: 'runners', + canActivate: [ UserRightGuard ], + data: { + userRight: UserRight.MANAGE_RUNNERS + }, + children: [ + { + path: '', + redirectTo: 'jobs-list', + pathMatch: 'full' + }, + + { + path: 'jobs-list', + component: RunnerJobListComponent, + data: { + meta: { + title: $localize`List runner jobs` + } + } + }, + + { + path: 'runners-list', + component: RunnerListComponent, + data: { + meta: { + title: $localize`List remote runners` + } + } + }, + + { + path: 'registration-tokens-list', + component: RunnerRegistrationTokenListComponent, + data: { + meta: { + title: $localize`List registration runner tokens` + } + } + } + ] + } +] diff --git a/client/src/app/+admin/system/system.routes.ts b/client/src/app/+admin/system/system.routes.ts index d180aa3b9..87e4b25b3 100644 --- a/client/src/app/+admin/system/system.routes.ts +++ b/client/src/app/+admin/system/system.routes.ts @@ -4,6 +4,7 @@ import { UserRight } from '@shared/models' import { DebugComponent } from './debug' import { JobsComponent } from './jobs/jobs.component' import { LogsComponent } from './logs' +import { RunnersRoutes } from './runners' export const SystemRoutes: Routes = [ { @@ -46,7 +47,9 @@ export const SystemRoutes: Routes = [ title: $localize`Debug` } } - } + }, + + ...RunnersRoutes ] } ] diff --git a/client/src/app/shared/shared-video-live/live-stream-information.component.ts b/client/src/app/shared/shared-video-live/live-stream-information.component.ts index 3dd59bb57..400a6fa01 100644 --- a/client/src/app/shared/shared-video-live/live-stream-information.component.ts +++ b/client/src/app/shared/shared-video-live/live-stream-information.component.ts @@ -43,7 +43,9 @@ export class LiveStreamInformationComponent { [LiveVideoError.BLACKLISTED]: $localize`Live blacklisted`, [LiveVideoError.DURATION_EXCEEDED]: $localize`Max duration exceeded`, [LiveVideoError.FFMPEG_ERROR]: $localize`Server error`, - [LiveVideoError.QUOTA_EXCEEDED]: $localize`Quota exceeded` + [LiveVideoError.QUOTA_EXCEEDED]: $localize`Quota exceeded`, + [LiveVideoError.RUNNER_JOB_CANCEL]: $localize`Runner job cancelled`, + [LiveVideoError.RUNNER_JOB_ERROR]: $localize`Error in runner job` } return errors[session.error] diff --git a/client/src/sass/class-helpers/_buttons.scss b/client/src/sass/class-helpers/_buttons.scss index 436bb48f4..fdbf6f9d2 100644 --- a/client/src/sass/class-helpers/_buttons.scss +++ b/client/src/sass/class-helpers/_buttons.scss @@ -35,3 +35,7 @@ .peertube-radio-container { @include peertube-radio-container; } + +.peertube-button-icon { + @include button-with-icon(18px, 3px, -1px); +} diff --git a/client/src/sass/class-helpers/_custom-bootstrap-helpers.scss b/client/src/sass/class-helpers/_custom-bootstrap-helpers.scss index dfe6f9050..b39c4144f 100644 --- a/client/src/sass/class-helpers/_custom-bootstrap-helpers.scss +++ b/client/src/sass/class-helpers/_custom-bootstrap-helpers.scss @@ -6,3 +6,7 @@ .fs-5-5 { @include font-size(18px); } + +.fs-7 { + @include font-size(14px); +}