From bbe5855fc404d212330b97e17092bed579fc8f61 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Fri, 3 Apr 2020 12:25:06 +0200 Subject: [PATCH] feat(admin-ui): Display live list of queued jobs --- .../lib/core/src/common/generated-types.ts | 60 ++++++++-- .../job-list/job-list.component.html | 20 ---- .../components/job-list/job-list.component.ts | 35 ------ .../job-queue-link.component.html | 15 +++ .../job-queue-link.component.scss} | 5 +- .../job-queue-link.component.ts | 33 ++++++ .../main-nav/main-nav.component.html | 2 +- .../admin-ui/src/lib/core/src/core.module.ts | 4 +- .../data/definitions/settings-definitions.ts | 31 ++++- .../data/providers/settings-data.service.ts | 31 ++++- .../providers/job-queue/job-queue.service.ts | 16 +-- .../admin-ui/src/lib/core/src/public_api.ts | 2 +- .../data-table/data-table.component.html | 9 +- .../src/shared/pipes/duration.pipe.spec.ts | 58 +++++++++ .../core/src/shared/pipes/duration.pipe.ts | 33 ++++++ .../src/shared/pipes/time-ago.pipe.spec.ts | 71 +++++++++++ .../core/src/shared/pipes/time-ago.pipe.ts | 38 ++++++ .../src/lib/core/src/shared/shared.module.ts | 4 + .../job-list/job-list.component.html | 112 ++++++++++++++++++ .../job-list/job-list.component.scss | 3 + .../components/job-list/job-list.component.ts | 92 ++++++++++++++ .../job-state-label.component.html | 7 ++ .../job-state-label.component.scss | 0 .../job-state-label.component.ts | 41 +++++++ .../src/lib/settings/src/settings.module.ts | 4 + .../src/lib/settings/src/settings.routes.ts | 24 ++-- .../src/lib/static/i18n-messages/en.json | 23 +++- 27 files changed, 672 insertions(+), 101 deletions(-) delete mode 100644 packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.html delete mode 100644 packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.ts create mode 100644 packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.html rename packages/admin-ui/src/lib/core/src/components/{job-list/job-list.component.scss => job-queue-link/job-queue-link.component.scss} (88%) create mode 100644 packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.ts create mode 100644 packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.spec.ts create mode 100644 packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.ts create mode 100644 packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.spec.ts create mode 100644 packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.ts create mode 100644 packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.html create mode 100644 packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.scss create mode 100644 packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.ts create mode 100644 packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.html create mode 100644 packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.scss create mode 100644 packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.ts diff --git a/packages/admin-ui/src/lib/core/src/common/generated-types.ts b/packages/admin-ui/src/lib/core/src/common/generated-types.ts index 421a2c6843..2fdf06ffdf 100644 --- a/packages/admin-ui/src/lib/core/src/common/generated-types.ts +++ b/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; + settledAt?: Maybe; queueName: Scalars['String']; state: JobState; progress: Scalars['Float']; data?: Maybe; result?: Maybe; error?: Maybe; - started: Scalars['DateTime']; - settled?: Maybe; isSettled: Scalars['Boolean']; duration: Scalars['Int']; }; export type JobFilterParameter = { createdAt?: Maybe; + startedAt?: Maybe; + settledAt?: Maybe; queueName?: Maybe; state?: Maybe; progress?: Maybe; - started?: Maybe; - settled?: Maybe; isSettled?: Maybe; duration?: Maybe; }; @@ -1306,13 +1306,19 @@ export type JobListOptions = { filter?: Maybe; }; +export type JobQueue = { + __typename?: 'JobQueue'; + name: Scalars['String']; + running: Scalars['Boolean']; +}; + export type JobSortParameter = { id?: Maybe; createdAt?: Maybe; + startedAt?: Maybe; + settledAt?: Maybe; queueName?: Maybe; progress?: Maybe; - started?: Maybe; - settled?: Maybe; duration?: Maybe; }; @@ -2842,6 +2848,7 @@ export type Query = { facets: FacetList; globalSettings: GlobalSettings; job?: Maybe; + jobQueues: Array; jobs: JobList; jobsById: Array; me?: Maybe; @@ -6097,7 +6104,7 @@ export type GetServerConfigQuery = ( export type JobInfoFragment = ( { __typename?: 'Job' } - & Pick + & Pick ); export type GetJobInfoQueryVariables = { @@ -6114,7 +6121,7 @@ export type GetJobInfoQuery = ( ); export type GetAllJobsQueryVariables = { - input?: Maybe; + options?: Maybe; }; @@ -6122,6 +6129,7 @@ export type GetAllJobsQuery = ( { __typename?: 'Query' } & { jobs: ( { __typename?: 'JobList' } + & Pick & { items: Array<( { __typename?: 'Job' } & JobInfoFragment @@ -6129,6 +6137,30 @@ export type GetAllJobsQuery = ( ) } ); +export type GetJobsByIdQueryVariables = { + ids: Array; +}; + + +export type GetJobsByIdQuery = ( + { __typename?: 'Query' } + & { jobsById: Array<( + { __typename?: 'Job' } + & JobInfoFragment + )> } +); + +export type GetJobQueueListQueryVariables = {}; + + +export type GetJobQueueListQuery = ( + { __typename?: 'Query' } + & { jobQueues: Array<( + { __typename?: 'JobQueue' } + & Pick + )> } +); + 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); +} + export namespace Reindex { export type Variables = ReindexMutationVariables; export type Mutation = ReindexMutation; diff --git a/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.html b/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.html deleted file mode 100644 index b317365ed0..0000000000 --- a/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.html +++ /dev/null @@ -1,20 +0,0 @@ - - - -
- {{ getJobName(job) | translate }} -
- - {{ job.progress }}% -
-
-
-
diff --git a/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.ts b/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.ts deleted file mode 100644 index da5bf5710f..0000000000 --- a/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.ts +++ /dev/null @@ -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; - - 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; - } -} diff --git a/packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.html b/packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.html new file mode 100644 index 0000000000..d9f8a8646c --- /dev/null +++ b/packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.html @@ -0,0 +1,15 @@ + + + + {{ 'common.jobs-in-progress' | translate: { count: activeJobCount } }} + + + + {{ 'nav.job-queue' | translate }} + + diff --git a/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.scss b/packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.scss similarity index 88% rename from packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.scss rename to packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.scss index 9d6cd95c4f..11cf4513fe 100644 --- a/packages/admin-ui/src/lib/core/src/components/job-list/job-list.component.scss +++ b/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; } diff --git a/packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.ts b/packages/admin-ui/src/lib/core/src/components/job-queue-link/job-queue-link.component.ts new file mode 100644 index 0000000000..9fdf8656c1 --- /dev/null +++ b/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(); + } + } +} diff --git a/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html b/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html index 322a127d31..ef8e6ed858 100644 --- a/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html +++ b/packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html @@ -25,7 +25,7 @@ diff --git a/packages/admin-ui/src/lib/core/src/core.module.ts b/packages/admin-ui/src/lib/core/src/core.module.ts index cfda0367d7..a4f89fd1cd 100644 --- a/packages/admin-ui/src/lib/core/src/core.module.ts +++ b/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, ], }) diff --git a/packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts b/packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts index 09da5fb30a..0152ca4b5d 100644 --- a/packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts +++ b/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 { diff --git a/packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts b/packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts index aaa49affc4..397c76fb65 100644 --- a/packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts +++ b/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(GET_ALL_JOBS, { - input: { ids }, + return this.baseDataService.query(GET_JOBS_BY_ID, { + ids, }); } + getAllJobs(options?: JobListOptions) { + return this.baseDataService.query(GET_JOBS_LIST, { + options, + }); + } + + getJobQueues() { + return this.baseDataService.query(GET_JOB_QUEUE_LIST); + } + getRunningJobs() { - return this.baseDataService.query(GET_ALL_JOBS, { - input: { state: JobState.RUNNING }, + return this.baseDataService.query(GET_JOBS_LIST, { + options: { + filter: { + state: { + eq: JobState.RUNNING, + }, + }, + }, }); } diff --git a/packages/admin-ui/src/lib/core/src/providers/job-queue/job-queue.service.ts b/packages/admin-ui/src/lib/core/src/providers/job-queue/job-queue.service.ts index 1ec020fd28..4426e6c0c0 100644 --- a/packages/admin-ui/src/lib/core/src/providers/job-queue/job-queue.service.ts +++ b/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(), ), - 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) { diff --git a/packages/admin-ui/src/lib/core/src/public_api.ts b/packages/admin-ui/src/lib/core/src/public_api.ts index 74ad8606b4..5b71315cd2 100644 --- a/packages/admin-ui/src/lib/core/src/public_api.ts +++ b/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'; diff --git a/packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html b/packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html index 9ae31f37cf..ab1fde58ae 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html +++ b/packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html @@ -17,13 +17,13 @@ @@ -59,6 +59,9 @@
-
{{ emptyStateLabel || ('common.no-results' | translate) }}
+
+ {{ emptyStateLabel }} + {{ 'common.no-results' | translate }} +
diff --git a/packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.spec.ts b/packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.spec.ts new file mode 100644 index 0000000000..612fdf43b0 --- /dev/null +++ b/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' }, + ]); + }); +}); diff --git a/packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.ts b/packages/admin-ui/src/lib/core/src/shared/pipes/duration.pipe.ts new file mode 100644 index 0000000000..5fb03fb716 --- /dev/null +++ b/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, + }); + } + } +} diff --git a/packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.spec.ts b/packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.spec.ts new file mode 100644 index 0000000000..276d309eb5 --- /dev/null +++ b/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 }]); + }); +}); diff --git a/packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.ts b/packages/admin-ui/src/lib/core/src/shared/pipes/time-ago.pipe.ts new file mode 100644 index 0000000000..8d1074bb96 --- /dev/null +++ b/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(); + } +} diff --git a/packages/admin-ui/src/lib/core/src/shared/shared.module.ts b/packages/admin-ui/src/lib/core/src/shared/shared.module.ts index 2af48e97bb..ea34c657ca 100644 --- a/packages/admin-ui/src/lib/core/src/shared/shared.module.ts +++ b/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({ diff --git a/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.html b/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.html new file mode 100644 index 0000000000..e3ec8d6dd6 --- /dev/null +++ b/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.html @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + {{ 'settings.all-job-queues' | translate }} + + + {{ item.name }} + + + + + + + + + + {{ 'settings.job-queue-name' | translate }} + {{ 'common.created-at' | translate }} + {{ 'settings.job-state' | translate }} + {{ 'settings.job-duration' | translate }} + {{ 'settings.job-result' | translate }} + + + + + + + + +
+ +
+
+
+ {{ job.queueName }} + + + {{ job.createdAt | timeAgo }} + + + + {{ job.duration | duration }} + + + + +
+ +
+
+
+ + + +
+ {{ job.error }} +
+
+
+ +
+
diff --git a/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.scss b/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.scss new file mode 100644 index 0000000000..be00781c26 --- /dev/null +++ b/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.scss @@ -0,0 +1,3 @@ +.result-detail { + margin: 0 12px; +} diff --git a/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.ts b/packages/admin-ui/src/lib/settings/src/components/job-list/job-list.component.ts new file mode 100644 index 0000000000..f0e5ac0a8f --- /dev/null +++ b/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 + implements OnInit { + queues$: Observable; + 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; + } +} diff --git a/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.html b/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.html new file mode 100644 index 0000000000..002971cd91 --- /dev/null +++ b/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.html @@ -0,0 +1,7 @@ + + + {{ job.state | titlecase }} + + {{ job.progress | percent }} + + diff --git a/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.scss b/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.ts b/packages/admin-ui/src/lib/settings/src/components/job-state-label/job-state-label.component.ts new file mode 100644 index 0000000000..5e34d35334 --- /dev/null +++ b/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'; + } + } +} diff --git a/packages/admin-ui/src/lib/settings/src/settings.module.ts b/packages/admin-ui/src/lib/settings/src/settings.module.ts index ed6f8ceb0e..4e990f1cba 100644 --- a/packages/admin-ui/src/lib/settings/src/settings.module.ts +++ b/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 {} diff --git a/packages/admin-ui/src/lib/settings/src/settings.routes.ts b/packages/admin-ui/src/lib/settings/src/settings.routes.ts index ad1c4c7635..eb179edc7d 100644 --- a/packages/admin-ui/src/lib/settings/src/settings.routes.ts +++ b/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', }); } diff --git a/packages/admin-ui/src/lib/static/i18n-messages/en.json b/packages/admin-ui/src/lib/static/i18n-messages/en.json index 4fbbb82c27..19c5abe7a6 100644 --- a/packages/admin-ui/src/lib/static/i18n-messages/en.json +++ b/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",