Browse Source

feat(admin-ui): Display live list of queued jobs

Michael Bromley 5 years ago
parent
commit
bbe5855fc4
27 changed files with 672 additions and 101 deletions
  1. 52 8
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 0 20
      packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.html
  3. 0 35
      packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.ts
  4. 15 0
      packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.html
  5. 3 2
      packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.scss
  6. 33 0
      packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.ts
  7. 1 1
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html
  8. 2 2
      packages/admin-ui/src/lib/core/src/core.module.ts
  9. 28 3
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  10. 26 5
      packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts
  11. 8 8
      packages/admin-ui/src/lib/core/src/providers/job-queue/job-queue.service.ts
  12. 1 1
      packages/admin-ui/src/lib/core/src/public_api.ts
  13. 6 3
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html
  14. 58 0
      packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.spec.ts
  15. 33 0
      packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.ts
  16. 71 0
      packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.spec.ts
  17. 38 0
      packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.ts
  18. 4 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  19. 112 0
      packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.html
  20. 3 0
      packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.scss
  21. 92 0
      packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.ts
  22. 7 0
      packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.html
  23. 0 0
      packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.scss
  24. 41 0
      packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.ts
  25. 4 0
      packages/admin-ui/src/lib/settings/src/settings.module.ts
  26. 16 8
      packages/admin-ui/src/lib/settings/src/settings.routes.ts
  27. 18 5
      packages/admin-ui/src/lib/static/i18n-messages/en.json

+ 52 - 8
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1270,25 +1270,25 @@ export type Job = Node & {
    __typename?: 'Job';
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
+  startedAt?: Maybe<Scalars['DateTime']>;
+  settledAt?: Maybe<Scalars['DateTime']>;
   queueName: Scalars['String'];
   state: JobState;
   progress: Scalars['Float'];
   data?: Maybe<Scalars['JSON']>;
   result?: Maybe<Scalars['JSON']>;
   error?: Maybe<Scalars['JSON']>;
-  started: Scalars['DateTime'];
-  settled?: Maybe<Scalars['DateTime']>;
   isSettled: Scalars['Boolean'];
   duration: Scalars['Int'];
 };
 
 export type JobFilterParameter = {
   createdAt?: Maybe<DateOperators>;
+  startedAt?: Maybe<DateOperators>;
+  settledAt?: Maybe<DateOperators>;
   queueName?: Maybe<StringOperators>;
   state?: Maybe<StringOperators>;
   progress?: Maybe<NumberOperators>;
-  started?: Maybe<DateOperators>;
-  settled?: Maybe<DateOperators>;
   isSettled?: Maybe<BooleanOperators>;
   duration?: Maybe<NumberOperators>;
 };
@@ -1306,13 +1306,19 @@ export type JobListOptions = {
   filter?: Maybe<JobFilterParameter>;
 };
 
+export type JobQueue = {
+   __typename?: 'JobQueue';
+  name: Scalars['String'];
+  running: Scalars['Boolean'];
+};
+
 export type JobSortParameter = {
   id?: Maybe<SortOrder>;
   createdAt?: Maybe<SortOrder>;
+  startedAt?: Maybe<SortOrder>;
+  settledAt?: Maybe<SortOrder>;
   queueName?: Maybe<SortOrder>;
   progress?: Maybe<SortOrder>;
-  started?: Maybe<SortOrder>;
-  settled?: Maybe<SortOrder>;
   duration?: Maybe<SortOrder>;
 };
 
@@ -2842,6 +2848,7 @@ export type Query = {
   facets: FacetList;
   globalSettings: GlobalSettings;
   job?: Maybe<Job>;
+  jobQueues: Array<JobQueue>;
   jobs: JobList;
   jobsById: Array<Job>;
   me?: Maybe<CurrentUser>;
@@ -6097,7 +6104,7 @@ export type GetServerConfigQuery = (
 
 export type JobInfoFragment = (
   { __typename?: 'Job' }
-  & Pick<Job, 'id' | 'queueName' | 'state' | 'progress' | 'duration' | 'result'>
+  & Pick<Job, 'id' | 'createdAt' | 'startedAt' | 'settledAt' | 'queueName' | 'state' | 'isSettled' | 'progress' | 'duration' | 'data' | 'result' | 'error'>
 );
 
 export type GetJobInfoQueryVariables = {
@@ -6114,7 +6121,7 @@ export type GetJobInfoQuery = (
 );
 
 export type GetAllJobsQueryVariables = {
-  input?: Maybe<JobListOptions>;
+  options?: Maybe<JobListOptions>;
 };
 
 
@@ -6122,6 +6129,7 @@ export type GetAllJobsQuery = (
   { __typename?: 'Query' }
   & { jobs: (
     { __typename?: 'JobList' }
+    & Pick<JobList, 'totalItems'>
     & { items: Array<(
       { __typename?: 'Job' }
       & JobInfoFragment
@@ -6129,6 +6137,30 @@ export type GetAllJobsQuery = (
   ) }
 );
 
+export type GetJobsByIdQueryVariables = {
+  ids: Array<Scalars['ID']>;
+};
+
+
+export type GetJobsByIdQuery = (
+  { __typename?: 'Query' }
+  & { jobsById: Array<(
+    { __typename?: 'Job' }
+    & JobInfoFragment
+  )> }
+);
+
+export type GetJobQueueListQueryVariables = {};
+
+
+export type GetJobQueueListQuery = (
+  { __typename?: 'Query' }
+  & { jobQueues: Array<(
+    { __typename?: 'JobQueue' }
+    & Pick<JobQueue, 'name' | 'running'>
+  )> }
+);
+
 export type ReindexMutationVariables = {};
 
 
@@ -7306,6 +7338,18 @@ export namespace GetAllJobs {
   export type Items = JobInfoFragment;
 }
 
+export namespace GetJobsById {
+  export type Variables = GetJobsByIdQueryVariables;
+  export type Query = GetJobsByIdQuery;
+  export type JobsById = JobInfoFragment;
+}
+
+export namespace GetJobQueueList {
+  export type Variables = GetJobQueueListQueryVariables;
+  export type Query = GetJobQueueListQuery;
+  export type JobQueues = (NonNullable<GetJobQueueListQuery['jobQueues'][0]>);
+}
+
 export namespace Reindex {
   export type Variables = ReindexMutationVariables;
   export type Mutation = ReindexMutation;

+ 0 - 20
packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.html

@@ -1,20 +0,0 @@
-<vdr-dropdown *ngIf="(activeJobs$ | async) as jobs">
-    <button
-        type="button"
-        class="btn btn-link btn-sm job-button"
-        [class.hidden]="jobs.length === 0"
-        vdrDropdownTrigger
-    >
-        <clr-icon shape="hourglass" class="has-badge"></clr-icon>
-        {{ 'common.jobs-in-progress' | translate: { count: jobs.length } }}
-    </button>
-    <vdr-dropdown-menu vdrPosition="top-right">
-        <div *ngFor="let job of (activeJobs$ | async); trackBy: trackById" class="job-row">
-            {{ getJobName(job) | translate }}
-            <div class="progress labeled">
-                <progress max="100" [value]="job.progress" data-displayval="0%"></progress>
-                <span>{{ job.progress }}%</span>
-            </div>
-        </div>
-    </vdr-dropdown-menu>
-</vdr-dropdown>

+ 0 - 35
packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.ts

@@ -1,35 +0,0 @@
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
-import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { Observable } from 'rxjs';
-
-import { JobInfoFragment } from '../../common/generated-types';
-import { JobQueueService } from '../../providers/job-queue/job-queue.service';
-
-@Component({
-    selector: 'vdr-job-list',
-    templateUrl: './job-list.component.html',
-    styleUrls: ['./job-list.component.scss'],
-    changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class JobListComponent implements OnInit {
-    activeJobs$: Observable<JobInfoFragment[]>;
-
-    constructor(private jobQueueService: JobQueueService) {}
-
-    ngOnInit() {
-        this.activeJobs$ = this.jobQueueService.activeJobs$;
-    }
-
-    getJobName(job: JobInfoFragment): string {
-        switch (job.name) {
-            case 'reindex':
-                return _('job.reindex');
-            default:
-                return job.name;
-        }
-    }
-
-    trackById(index: number, item: JobInfoFragment) {
-        return item.id;
-    }
-}

+ 15 - 0
packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.html

@@ -0,0 +1,15 @@
+<a
+    type="button"
+    class="btn btn-link btn-sm job-button"
+    [class.active]="activeJobCount > 0"
+    [routerLink]="['/settings/jobs']"
+>
+    <ng-container *ngIf="activeJobCount; else noJobs">
+        <clr-icon shape="hourglass" class="has-badge"></clr-icon>
+        {{ 'common.jobs-in-progress' | translate: { count: activeJobCount } }}
+    </ng-container>
+    <ng-template #noJobs>
+        <clr-icon shape="hourglass"></clr-icon>
+        {{ 'nav.job-queue' | translate }}
+    </ng-template>
+</a>

+ 3 - 2
packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.scss → packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.scss

@@ -2,8 +2,9 @@
 
 :host {
     position: fixed;
-    bottom: 12px;
-    left: 12px;
+    display: block;
+    bottom: 0;
+    left: 0;
     z-index: 5;
 }
 

+ 33 - 0
packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.ts

@@ -0,0 +1,33 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { JobQueueService } from '../../providers/job-queue/job-queue.service';
+
+@Component({
+    selector: 'vdr-job-link',
+    templateUrl: './job-queue-link.component.html',
+    styleUrls: ['./job-queue-link.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class JobQueueLinkComponent implements OnInit, OnDestroy {
+    activeJobCount: number;
+    private subscription: Subscription;
+
+    constructor(private jobQueueService: JobQueueService, private changeDetectorRef: ChangeDetectorRef) {}
+
+    ngOnInit() {
+        this.subscription = this.jobQueueService.activeJobs$
+            .pipe(map((jobs) => jobs.length))
+            .subscribe((value) => {
+                this.activeJobCount = value;
+                this.changeDetectorRef.markForCheck();
+            });
+    }
+
+    ngOnDestroy(): void {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+}

+ 1 - 1
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html

@@ -25,7 +25,7 @@
             </section>
         </ng-container>
         <section class="nav-group">
-            <vdr-job-list></vdr-job-list>
+            <vdr-job-link></vdr-job-link>
         </section>
     </section>
 </nav>

+ 2 - 2
packages/admin-ui/src/lib/core/src/core.module.ts

@@ -11,7 +11,7 @@ import { getDefaultLanguage } from './common/utilities/get-default-language';
 import { AppShellComponent } from './components/app-shell/app-shell.component';
 import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
 import { ChannelSwitcherComponent } from './components/channel-switcher/channel-switcher.component';
-import { JobListComponent } from './components/job-list/job-list.component';
+import { JobQueueLinkComponent } from './components/job-queue-link/job-queue-link.component';
 import { MainNavComponent } from './components/main-nav/main-nav.component';
 import { NotificationComponent } from './components/notification/notification.component';
 import { OverlayHostComponent } from './components/overlay-host/overlay-host.component';
@@ -49,7 +49,7 @@ import { SharedModule } from './shared/shared.module';
         OverlayHostComponent,
         NotificationComponent,
         UiLanguageSwitcherDialogComponent,
-        JobListComponent,
+        JobQueueLinkComponent,
         ChannelSwitcherComponent,
     ],
 })

+ 28 - 3
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -573,11 +573,17 @@ export const GET_SERVER_CONFIG = gql`
 export const JOB_INFO_FRAGMENT = gql`
     fragment JobInfo on Job {
         id
+        createdAt
+        startedAt
+        settledAt
         queueName
         state
+        isSettled
         progress
         duration
+        data
         result
+        error
     }
 `;
 
@@ -590,17 +596,36 @@ export const GET_JOB_INFO = gql`
     ${JOB_INFO_FRAGMENT}
 `;
 
-export const GET_ALL_JOBS = gql`
-    query GetAllJobs($input: JobListOptions) {
-        jobs(options: $input) {
+export const GET_JOBS_LIST = gql`
+    query GetAllJobs($options: JobListOptions) {
+        jobs(options: $options) {
             items {
                 ...JobInfo
             }
+            totalItems
         }
     }
     ${JOB_INFO_FRAGMENT}
 `;
 
+export const GET_JOBS_BY_ID = gql`
+    query GetJobsById($ids: [ID!]!) {
+        jobsById(jobIds: $ids) {
+            ...JobInfo
+        }
+    }
+    ${JOB_INFO_FRAGMENT}
+`;
+
+export const GET_JOB_QUEUE_LIST = gql`
+    query GetJobQueueList {
+        jobQueues {
+            name
+            running
+        }
+    }
+`;
+
 export const REINDEX = gql`
     mutation Reindex {
         reindex {

+ 26 - 5
packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts

@@ -26,6 +26,8 @@ import {
     GetCountryList,
     GetGlobalSettings,
     GetJobInfo,
+    GetJobQueueList,
+    GetJobsById,
     GetPaymentMethod,
     GetPaymentMethodList,
     GetTaxCategories,
@@ -34,6 +36,7 @@ import {
     GetTaxRateList,
     GetZone,
     GetZones,
+    JobListOptions,
     JobState,
     RemoveMembersFromZone,
     SearchForTestOrder,
@@ -64,7 +67,6 @@ import {
     DELETE_TAX_CATEGORY,
     DELETE_TAX_RATE,
     GET_ACTIVE_CHANNEL,
-    GET_ALL_JOBS,
     GET_AVAILABLE_COUNTRIES,
     GET_CHANNEL,
     GET_CHANNELS,
@@ -72,6 +74,9 @@ import {
     GET_COUNTRY_LIST,
     GET_GLOBAL_SETTINGS,
     GET_JOB_INFO,
+    GET_JOB_QUEUE_LIST,
+    GET_JOBS_BY_ID,
+    GET_JOBS_LIST,
     GET_PAYMENT_METHOD,
     GET_PAYMENT_METHOD_LIST,
     GET_TAX_CATEGORIES,
@@ -331,14 +336,30 @@ export class SettingsDataService {
     }
 
     pollJobs(ids: string[]) {
-        return this.baseDataService.query<GetAllJobs.Query, GetAllJobs.Variables>(GET_ALL_JOBS, {
-            input: { ids },
+        return this.baseDataService.query<GetJobsById.Query, GetJobsById.Variables>(GET_JOBS_BY_ID, {
+            ids,
         });
     }
 
+    getAllJobs(options?: JobListOptions) {
+        return this.baseDataService.query<GetAllJobs.Query, GetAllJobs.Variables>(GET_JOBS_LIST, {
+            options,
+        });
+    }
+
+    getJobQueues() {
+        return this.baseDataService.query<GetJobQueueList.Query>(GET_JOB_QUEUE_LIST);
+    }
+
     getRunningJobs() {
-        return this.baseDataService.query<GetAllJobs.Query, GetAllJobs.Variables>(GET_ALL_JOBS, {
-            input: { state: JobState.RUNNING },
+        return this.baseDataService.query<GetAllJobs.Query, GetAllJobs.Variables>(GET_JOBS_LIST, {
+            options: {
+                filter: {
+                    state: {
+                        eq: JobState.RUNNING,
+                    },
+                },
+            },
         });
     }
 

+ 8 - 8
packages/admin-ui/src/lib/core/src/providers/job-queue/job-queue.service.ts

@@ -23,14 +23,14 @@ export class JobQueueService implements OnDestroy {
                 (jobMap, job) => this.handleJob(jobMap, job),
                 new Map<string, JobInfoFragment>(),
             ),
-            map(jobMap => Array.from(jobMap.values())),
+            map((jobMap) => Array.from(jobMap.values())),
             debounceTime(500),
             shareReplay(1),
         );
 
         this.subscription = this.activeJobs$
             .pipe(
-                switchMap(jobs => {
+                switchMap((jobs) => {
                     if (jobs.length) {
                         return interval(2500).pipe(mapTo(jobs));
                     } else {
@@ -38,10 +38,10 @@ export class JobQueueService implements OnDestroy {
                     }
                 }),
             )
-            .subscribe(jobs => {
+            .subscribe((jobs) => {
                 if (jobs.length) {
-                    this.dataService.settings.pollJobs(jobs.map(j => j.id)).single$.subscribe(data => {
-                        data.jobs.forEach(job => {
+                    this.dataService.settings.pollJobs(jobs.map((j) => j.id)).single$.subscribe((data) => {
+                        data.jobsById.forEach((job) => {
                             this.updateJob$.next(job);
                         });
                     });
@@ -62,13 +62,13 @@ export class JobQueueService implements OnDestroy {
         timer(delay)
             .pipe(
                 switchMap(() =>
-                    this.dataService.client.userStatus().mapSingle(data => data.userStatus.isLoggedIn),
+                    this.dataService.client.userStatus().mapSingle((data) => data.userStatus.isLoggedIn),
                 ),
-                switchMap(isLoggedIn =>
+                switchMap((isLoggedIn) =>
                     isLoggedIn ? this.dataService.settings.getRunningJobs().single$ : EMPTY,
                 ),
             )
-            .subscribe(data => data.jobs.forEach(job => this.updateJob$.next(job)));
+            .subscribe((data) => data.jobs.items.forEach((job) => this.updateJob$.next(job)));
     }
 
     addJob(jobId: string, onComplete?: (job: JobInfoFragment) => void) {

+ 1 - 1
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -21,7 +21,7 @@ export * from './common/version';
 export * from './components/app-shell/app-shell.component';
 export * from './components/breadcrumb/breadcrumb.component';
 export * from './components/channel-switcher/channel-switcher.component';
-export * from './components/job-list/job-list.component';
+export * from './components/job-queue-link/job-queue-link.component';
 export * from './components/main-nav/main-nav.component';
 export * from './components/notification/notification.component';
 export * from './components/overlay-host/overlay-host.component';

+ 6 - 3
packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html

@@ -17,13 +17,13 @@
         <tbody>
             <tr
                 *ngFor="
-                    let item of (items
+                    let item of items
                         | paginate
                             : {
                                   itemsPerPage: itemsPerPage,
                                   currentPage: currentPage,
                                   totalItems: totalItems
-                              });
+                              };
                     index as i
                 "
             >
@@ -59,6 +59,9 @@
 <ng-template #emptyPlaceholder>
     <div class="empty-state">
         <clr-icon shape="bubble-exclamation" size="64"></clr-icon>
-        <div class="empty-label">{{ emptyStateLabel || ('common.no-results' | translate) }}</div>
+        <div class="empty-label">
+            <ng-container *ngIf="emptyStateLabel; else defaultEmptyLabel">{{ emptyStateLabel }}</ng-container>
+            <ng-template #defaultEmptyLabel>{{ 'common.no-results' | translate }}</ng-template>
+        </div>
     </div>
 </ng-template>

+ 58 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.spec.ts

@@ -0,0 +1,58 @@
+import { DurationPipe } from './duration.pipe';
+
+describe('DurationPipe', () => {
+    let mockI18nService: any;
+    beforeEach(() => {
+        mockI18nService = {
+            translate: jasmine.createSpy('translate'),
+        };
+    });
+
+    it('ms', () => {
+        const pipe = new DurationPipe(mockI18nService);
+
+        pipe.transform(1);
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual([
+            'datetime.duration-milliseconds',
+            { ms: 1 },
+        ]);
+
+        pipe.transform(999);
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual([
+            'datetime.duration-milliseconds',
+            { ms: 999 },
+        ]);
+    });
+
+    it('s', () => {
+        const pipe = new DurationPipe(mockI18nService);
+
+        pipe.transform(1000);
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual(['datetime.duration-seconds', { s: 1.0 }]);
+
+        pipe.transform(2567);
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual(['datetime.duration-seconds', { s: 2.6 }]);
+
+        pipe.transform(59.3 * 1000);
+        expect(mockI18nService.translate.calls.argsFor(2)).toEqual([
+            'datetime.duration-seconds',
+            { s: 59.3 },
+        ]);
+    });
+
+    it('m:s', () => {
+        const pipe = new DurationPipe(mockI18nService);
+
+        pipe.transform(60 * 1000);
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual([
+            'datetime.duration-minutes:seconds',
+            { m: 1, s: '00' },
+        ]);
+
+        pipe.transform(125.23 * 1000);
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual([
+            'datetime.duration-minutes:seconds',
+            { m: 2, s: '05' },
+        ]);
+    });
+});

+ 33 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.ts

@@ -0,0 +1,33 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+
+import { I18nService } from '../../providers/i18n/i18n.service';
+
+/**
+ * Displays a number of milliseconds in a more human-readable format,
+ * e.g. "12ms", "33s", "2:03m"
+ */
+@Pipe({
+    name: 'duration',
+})
+export class DurationPipe implements PipeTransform {
+    constructor(private i18nService: I18nService) {}
+
+    transform(value: number): string {
+        if (value < 1000) {
+            return this.i18nService.translate(_('datetime.duration-milliseconds'), { ms: value });
+        } else if (value < 1000 * 60) {
+            const seconds1dp = +(value / 1000).toFixed(1);
+            return this.i18nService.translate(_('datetime.duration-seconds'), { s: seconds1dp });
+        } else {
+            const minutes = Math.floor(value / (1000 * 60));
+            const seconds = Math.round((value % (1000 * 60)) / 1000)
+                .toString()
+                .padStart(2, '0');
+            return this.i18nService.translate(_('datetime.duration-minutes:seconds'), {
+                m: minutes,
+                s: seconds,
+            });
+        }
+    }
+}

+ 71 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.spec.ts

@@ -0,0 +1,71 @@
+import { TimeAgoPipe } from './time-ago.pipe';
+
+describe('TimeAgoPipe', () => {
+    let mockI18nService: any;
+    beforeEach(() => {
+        mockI18nService = {
+            translate: jasmine.createSpy('translate'),
+        };
+    });
+
+    it('seconds ago', () => {
+        const pipe = new TimeAgoPipe(mockI18nService);
+
+        pipe.transform('2020-02-04T16:15:10.100Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual(['datetime.ago-seconds', { count: 0 }]);
+
+        pipe.transform('2020-02-04T16:15:07.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual(['datetime.ago-seconds', { count: 3 }]);
+
+        pipe.transform('2020-02-04T16:14:20.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(2)).toEqual(['datetime.ago-seconds', { count: 50 }]);
+    });
+
+    it('minutes ago', () => {
+        const pipe = new TimeAgoPipe(mockI18nService);
+
+        pipe.transform('2020-02-04T16:13:10.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual(
+            ['datetime.ago-minutes', { count: 2 }],
+            'a',
+        );
+
+        pipe.transform('2020-02-04T16:12:10.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual(
+            ['datetime.ago-minutes', { count: 3 }],
+            'b',
+        );
+
+        pipe.transform('2020-02-04T15:20:10.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(2)).toEqual(
+            ['datetime.ago-minutes', { count: 55 }],
+            'c',
+        );
+    });
+
+    it('hours ago', () => {
+        const pipe = new TimeAgoPipe(mockI18nService);
+
+        pipe.transform('2020-02-04T14:15:10.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual(['datetime.ago-hours', { count: 2 }]);
+
+        pipe.transform('2020-02-04T02:15:07.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual(['datetime.ago-hours', { count: 14 }]);
+
+        pipe.transform('2020-02-03T17:14:20.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(2)).toEqual(['datetime.ago-hours', { count: 23 }]);
+    });
+
+    it('days ago', () => {
+        const pipe = new TimeAgoPipe(mockI18nService);
+
+        pipe.transform('2020-02-03T16:15:10.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(0)).toEqual(['datetime.ago-days', { count: 1 }]);
+
+        pipe.transform('2020-02-01T02:15:07.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(1)).toEqual(['datetime.ago-days', { count: 3 }]);
+
+        pipe.transform('2020-01-03T17:14:20.500Z', '2020-02-04T16:15:10.500Z');
+        expect(mockI18nService.translate.calls.argsFor(2)).toEqual(['datetime.ago-days', { count: 31 }]);
+    });
+});

+ 38 - 0
packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.ts

@@ -0,0 +1,38 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import dayjs from 'dayjs';
+
+import { I18nService } from '../../providers/i18n/i18n.service';
+
+/**
+ * Converts a date into the format "3 minutes ago", "5 hours ago" etc.
+ */
+@Pipe({
+    name: 'timeAgo',
+    pure: false,
+})
+export class TimeAgoPipe implements PipeTransform {
+    constructor(private i18nService: I18nService) {}
+
+    transform(value: string | Date, nowVal?: string | Date): string {
+        const then = dayjs(value);
+        const now = dayjs(nowVal || new Date());
+        const secondsDiff = now.diff(then, 'second');
+        const durations: Array<[number, string]> = [
+            [60, _('datetime.ago-seconds')],
+            [3600, _('datetime.ago-minutes')],
+            [86400, _('datetime.ago-hours')],
+            [Number.MAX_SAFE_INTEGER, _('datetime.ago-days')],
+        ];
+
+        let lastUpperBound = 1;
+        for (const [upperBound, translationToken] of durations) {
+            if (secondsDiff < upperBound) {
+                const count = Math.max(0, Math.floor(secondsDiff / lastUpperBound));
+                return this.i18nService.translate(translationToken, { count });
+            }
+            lastUpperBound = upperBound;
+        }
+        return then.format();
+    }
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -75,11 +75,13 @@ import { AssetPreviewPipe } from './pipes/asset-preview.pipe';
 import { ChannelLabelPipe } from './pipes/channel-label.pipe';
 import { CurrencyNamePipe } from './pipes/currency-name.pipe';
 import { CustomFieldLabelPipe } from './pipes/custom-field-label.pipe';
+import { DurationPipe } from './pipes/duration.pipe';
 import { FileSizePipe } from './pipes/file-size.pipe';
 import { HasPermissionPipe } from './pipes/has-permission.pipe';
 import { SentenceCasePipe } from './pipes/sentence-case.pipe';
 import { SortPipe } from './pipes/sort.pipe';
 import { StringToColorPipe } from './pipes/string-to-color.pipe';
+import { TimeAgoPipe } from './pipes/time-ago.pipe';
 import { CanDeactivateDetailGuard } from './providers/routing/can-deactivate-detail-guard';
 
 const IMPORTS = [
@@ -160,6 +162,8 @@ const DECLARATIONS = [
     AssetPreviewPipe,
     LinkDialogComponent,
     ExternalImageDialogComponent,
+    TimeAgoPipe,
+    DurationPipe,
 ];
 
 @NgModule({

+ 112 - 0
packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.html

@@ -0,0 +1,112 @@
+<vdr-action-bar>
+    <vdr-ab-left>
+        <clr-checkbox-container>
+            <clr-checkbox-wrapper>
+                <input type="checkbox" clrCheckbox [formControl]="liveUpdate" name="live-update"/>
+                <label>{{ 'common.live-update' | translate }}</label>
+            </clr-checkbox-wrapper>
+            <clr-checkbox-wrapper>
+                <input
+                    type="checkbox"
+                    clrCheckbox
+                    [formControl]="hideSettled"
+                    name="hide-settled"
+                    (change)="refresh()"
+                />
+                <label>{{ 'settings.hide-settled-jobs' | translate }}</label>
+            </clr-checkbox-wrapper>
+        </clr-checkbox-container>
+    </vdr-ab-left>
+    <vdr-ab-right>
+        <ng-select
+            [addTag]="false"
+            [items]="queues$ | async"
+            [hideSelected]="true"
+            [multiple]="false"
+            [markFirst]="false"
+            [clearable]="false"
+            [searchable]="false"
+            bindValue="name"
+            [formControl]="queueFilter"
+            (change)="refresh()"
+        >
+            <ng-template ng-label-tmp ng-option-tmp let-item="item">
+                <ng-container *ngIf="item.name === 'all'; else others">
+                    {{ 'settings.all-job-queues' | translate }}
+                </ng-container>
+                <ng-template #others>
+                    <vdr-chip [colorFrom]="item.name">{{ item.name }}</vdr-chip>
+                </ng-template>
+            </ng-template>
+        </ng-select>
+        <vdr-action-bar-items locationId="job-list"></vdr-action-bar-items>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<vdr-data-table
+    [items]="items$ | async"
+    [itemsPerPage]="itemsPerPage$ | async"
+    [totalItems]="totalItems$ | async"
+    [currentPage]="currentPage$ | async"
+    (pageChange)="setPageNumber($event)"
+    (itemsPerPageChange)="setItemsPerPage($event)"
+>
+    <vdr-dt-column></vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.job-queue-name' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'common.created-at' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.job-state' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.job-duration' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.job-result' | translate }}</vdr-dt-column>
+    <ng-template let-job="item">
+        <td class="left align-middle">
+            <vdr-entity-info [entity]="job"></vdr-entity-info>
+        </td>
+        <td class="left align-middle">
+            <vdr-dropdown *ngIf="job.data">
+                <button
+                    class="btn btn-link btn-icon"
+                    vdrDropdownTrigger
+                    [title]="'settings.job-data' | translate"
+                >
+                    <clr-icon shape="details"></clr-icon>
+                </button>
+                <vdr-dropdown-menu>
+                    <div class="result-detail">
+                        <vdr-object-tree [value]="job.data"></vdr-object-tree>
+                    </div>
+                </vdr-dropdown-menu>
+            </vdr-dropdown>
+            <vdr-chip [colorFrom]="job.queueName">{{ job.queueName }}</vdr-chip>
+        </td>
+
+        <td class="left align-middle">{{ job.createdAt | timeAgo }}</td>
+        <td class="left align-middle">
+            <vdr-job-state-label [job]="job"></vdr-job-state-label>
+        </td>
+        <td class="left align-middle">{{ job.duration | duration }}</td>
+        <td class="left align-middle">
+            <vdr-dropdown *ngIf="hasResult(job)">
+                <button class="btn btn-link btn-sm details-button" vdrDropdownTrigger>
+                    <clr-icon shape="details"></clr-icon>
+                    {{ 'settings.job-result' | translate }}
+                </button>
+                <vdr-dropdown-menu>
+                    <div class="result-detail">
+                        <vdr-object-tree [value]="job.result"></vdr-object-tree>
+                    </div>
+                </vdr-dropdown-menu>
+            </vdr-dropdown>
+            <vdr-dropdown *ngIf="job.error">
+                <button class="btn btn-link btn-sm details-button" vdrDropdownTrigger>
+                    <clr-icon shape="exclamation-circle"></clr-icon>
+                    {{ 'settings.job-error' | translate }}
+                </button>
+                <vdr-dropdown-menu>
+                    <div class="result-detail">
+                        {{ job.error }}
+                    </div>
+                </vdr-dropdown-menu>
+            </vdr-dropdown>
+        </td>
+    </ng-template>
+</vdr-data-table>

+ 3 - 0
packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.scss

@@ -0,0 +1,3 @@
+.result-detail {
+    margin: 0 12px;
+}

+ 92 - 0
packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.ts

@@ -0,0 +1,92 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import {
+    BaseListComponent,
+    DataService,
+    GetAllJobs,
+    GetFacetList,
+    GetJobQueueList,
+    ModalService,
+    NotificationService,
+    SortOrder,
+} from '@vendure/admin-ui/core';
+import { Observable, timer } from 'rxjs';
+import { filter, map, takeUntil } from 'rxjs/operators';
+
+@Component({
+    selector: 'vdr-job-link',
+    templateUrl: './job-list.component.html',
+    styleUrls: ['./job-list.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class JobListComponent extends BaseListComponent<GetAllJobs.Query, GetAllJobs.Items>
+    implements OnInit {
+    queues$: Observable<GetJobQueueList.JobQueues[]>;
+    liveUpdate = new FormControl(true);
+    hideSettled = new FormControl(true);
+    queueFilter = new FormControl('all');
+
+    constructor(
+        private dataService: DataService,
+        private modalService: ModalService,
+        private notificationService: NotificationService,
+        router: Router,
+        route: ActivatedRoute,
+    ) {
+        super(router, route);
+        super.setQueryFn(
+            (...args: any[]) => this.dataService.settings.getAllJobs(...args),
+            (data) => data.jobs,
+            (skip, take) => {
+                const queueFilter =
+                    this.queueFilter.value === 'all' ? null : { queueName: { eq: this.queueFilter.value } };
+                const hideSettled = this.hideSettled.value;
+                return {
+                    options: {
+                        skip,
+                        take,
+                        filter: {
+                            ...queueFilter,
+                            ...(hideSettled ? { isSettled: { eq: false } } : {}),
+                        },
+                        sort: {
+                            createdAt: SortOrder.DESC,
+                        },
+                    },
+                };
+            },
+        );
+    }
+
+    ngOnInit(): void {
+        super.ngOnInit();
+        timer(5000, 2000)
+            .pipe(
+                takeUntil(this.destroy$),
+                filter(() => this.liveUpdate.value),
+            )
+            .subscribe(() => {
+                this.refresh();
+            });
+        this.queues$ = this.dataService.settings
+            .getJobQueues()
+            .mapStream((res) => res.jobQueues)
+            .pipe(
+                map((queues) => {
+                    return [{ name: 'all', running: true }, ...queues];
+                }),
+            );
+    }
+
+    hasResult(job: GetAllJobs.Items): boolean {
+        const result = job.result;
+        if (result == null) {
+            return false;
+        }
+        if (typeof result === 'object') {
+            return Object.keys(result).length > 0;
+        }
+        return true;
+    }
+}

+ 7 - 0
packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.html

@@ -0,0 +1,7 @@
+<vdr-chip [colorType]="colorType">
+    <clr-icon [attr.shape]="iconShape"></clr-icon>
+    {{ job.state | titlecase }}
+    <span *ngIf="job.state === 'RUNNING'">
+        {{ job.progress | percent }}
+    </span>
+</vdr-chip>

+ 0 - 0
packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.scss


+ 41 - 0
packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.ts

@@ -0,0 +1,41 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { JobInfoFragment, JobState } from '@vendure/admin-ui/core';
+
+@Component({
+    selector: 'vdr-job-state-label',
+    templateUrl: './job-state-label.component.html',
+    styleUrls: ['./job-state-label.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class JobStateLabelComponent {
+    @Input()
+    job: JobInfoFragment;
+
+    get iconShape(): string {
+        switch (this.job.state) {
+            case JobState.COMPLETED:
+                return 'check-circle';
+            case JobState.FAILED:
+                return 'exclamation-circle';
+            case JobState.PENDING:
+            case JobState.RETRYING:
+                return 'hourglass';
+            case JobState.RUNNING:
+                return 'sync';
+        }
+    }
+
+    get colorType(): string {
+        switch (this.job.state) {
+            case JobState.COMPLETED:
+                return 'success';
+            case JobState.FAILED:
+                return 'error';
+            case JobState.PENDING:
+            case JobState.RETRYING:
+                return '';
+            case JobState.RUNNING:
+                return 'warning';
+        }
+    }
+}

+ 4 - 0
packages/admin-ui/src/lib/settings/src/settings.module.ts

@@ -9,6 +9,8 @@ import { ChannelListComponent } from './components/channel-list/channel-list.com
 import { CountryDetailComponent } from './components/country-detail/country-detail.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { GlobalSettingsComponent } from './components/global-settings/global-settings.component';
+import { JobListComponent } from './components/job-list/job-list.component';
+import { JobStateLabelComponent } from './components/job-state-label/job-state-label.component';
 import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component';
 import { PaymentMethodListComponent } from './components/payment-method-list/payment-method-list.component';
 import { PermissionGridComponent } from './components/permission-grid/permission-grid.component';
@@ -53,6 +55,8 @@ import { settingsRoutes } from './settings.routes';
         TestAddressFormComponent,
         ShippingMethodTestResultComponent,
         ShippingEligibilityTestResultComponent,
+        JobListComponent,
+        JobStateLabelComponent,
     ],
 })
 export class SettingsModule {}

+ 16 - 8
packages/admin-ui/src/lib/settings/src/settings.routes.ts

@@ -20,6 +20,7 @@ import { ChannelListComponent } from './components/channel-list/channel-list.com
 import { CountryDetailComponent } from './components/country-detail/country-detail.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { GlobalSettingsComponent } from './components/global-settings/global-settings.component';
+import { JobListComponent } from './components/job-list/job-list.component';
 import { PaymentMethodDetailComponent } from './components/payment-method-detail/payment-method-detail.component';
 import { PaymentMethodListComponent } from './components/payment-method-list/payment-method-list.component';
 import { RoleDetailComponent } from './components/role-detail/role-detail.component';
@@ -172,6 +173,13 @@ export const settingsRoutes: Route[] = [
             breadcrumb: _('breadcrumb.global-settings'),
         },
     },
+    {
+        path: 'jobs',
+        component: JobListComponent,
+        data: {
+            breadcrumb: _('breadcrumb.job-queue'),
+        },
+    },
 ];
 
 export function administratorBreadcrumb(data: any, params: any) {
@@ -179,7 +187,7 @@ export function administratorBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.administrators',
-        getName: admin => `${admin.firstName} ${admin.lastName}`,
+        getName: (admin) => `${admin.firstName} ${admin.lastName}`,
         route: 'administrators',
     });
 }
@@ -189,7 +197,7 @@ export function channelBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.channels',
-        getName: channel => channel.code,
+        getName: (channel) => channel.code,
         route: 'channels',
     });
 }
@@ -199,7 +207,7 @@ export function roleBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.roles',
-        getName: role => role.description,
+        getName: (role) => role.description,
         route: 'roles',
     });
 }
@@ -209,7 +217,7 @@ export function taxCategoryBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.tax-categories',
-        getName: category => category.name,
+        getName: (category) => category.name,
         route: 'tax-categories',
     });
 }
@@ -219,7 +227,7 @@ export function taxRateBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.tax-rates',
-        getName: category => category.name,
+        getName: (category) => category.name,
         route: 'tax-rates',
     });
 }
@@ -229,7 +237,7 @@ export function countryBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.countries',
-        getName: promotion => promotion.name,
+        getName: (promotion) => promotion.name,
         route: 'countries',
     });
 }
@@ -239,7 +247,7 @@ export function shippingMethodBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.shipping-methods',
-        getName: method => method.description,
+        getName: (method) => method.description,
         route: 'shipping-methods',
     });
 }
@@ -249,7 +257,7 @@ export function paymentMethodBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.payment-methods',
-        getName: method => method.code,
+        getName: (method) => method.code,
         route: 'payment-methods',
     });
 }

+ 18 - 5
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -33,6 +33,7 @@
     "dashboard": "Dashboard",
     "facets": "Facets",
     "global-settings": "Global settings",
+    "job-queue": "Job queue",
     "manage-variants": "Manage variants",
     "orders": "Orders",
     "payment-methods": "Payment methods",
@@ -100,7 +101,6 @@
     "product-details": "Product details",
     "product-name": "Product name",
     "product-variants": "Product variants",
-    "public": "Public",
     "rebuild-search-index": "Rebuild search index",
     "reindex-error": "An error occurred while rebuilding search index",
     "reindex-successful": "Indexed {count, plural, one {product variant} other {{count} product variants}} in {time}ms",
@@ -112,7 +112,6 @@
     "search-product-name-or-code": "Search by product name or code",
     "sku": "SKU",
     "slug": "Slug",
-    "slug-pattern-error": "The slug may only contain letters, numbers, - and _",
     "stock-on-hand": "Stock",
     "tax-category": "Tax category",
     "taxes": "Taxes",
@@ -154,6 +153,7 @@
     "jobs-in-progress": "{ count } {count, plural, one {job} other {jobs}} in progress",
     "language": "Language",
     "launch-extension": "Launch extension",
+    "live-update": "Live update",
     "log-out": "Log out",
     "login": "Log in",
     "more": "More...",
@@ -219,6 +219,13 @@
     "verified": "Verified"
   },
   "datetime": {
+    "ago-days": "{count, plural, one {1 day} other {{count} days}} ago",
+    "ago-hours": "{count, plural, one {1 hr} other {{count} hrs}} ago",
+    "ago-minutes": "{count, plural, one {1 min} other {{count} mins}} ago",
+    "ago-seconds": "{count, plural, =0 {just now} one {1 sec ago} other {{count} secs ago}}",
+    "duration-milliseconds": "{ms}ms",
+    "duration-minutes:seconds": "{m}:{s}m",
+    "duration-seconds": "{s}s",
     "month-apr": "April",
     "month-aug": "August",
     "month-dec": "December",
@@ -256,9 +263,6 @@
     "facet-value-form-values-do-not-match": "The number of values in the facet form does not match the actual number of values",
     "product-variant-form-values-do-not-match": "The number of variants in the product form does not match the actual number of variants"
   },
-  "job": {
-    "reindex": "Rebuilding search index"
-  },
   "lang": {
     "aa": "Afar",
     "ab": "Abkhazian",
@@ -466,6 +470,7 @@
     "customers": "Customers",
     "facets": "Facets",
     "global-settings": "Global settings",
+    "job-queue": "Job queue",
     "marketing": "Marketing",
     "orders": "Orders",
     "payment-methods": "Payment methods",
@@ -574,6 +579,7 @@
     "add-countries-to-zone-success": "Added { countryCount } {countryCount, plural, one {country} other {countries}} to zone \"{ zoneName }\"",
     "add-products-to-test-order": "Add products to the test order",
     "administrator": "Administrator",
+    "all-job-queues": "All job queues",
     "catalog": "Catalog",
     "channel": "Channel",
     "channel-token": "Channel token",
@@ -597,6 +603,13 @@
     "elibigle": "Eligible",
     "email-address": "Email address",
     "first-name": "First name",
+    "hide-settled-jobs": "Hide settled jobs",
+    "job-data": "Job data",
+    "job-duration": "Duration",
+    "job-error": "Job error",
+    "job-queue-name": "Queue name",
+    "job-result": "Job result",
+    "job-state": "Job state",
     "last-name": "Last name",
     "no-eligible-shipping-methods": "No eligible shipping methods",
     "order": "Order",