diff --git a/admin-ui/src/app/catalog/components/product-list/product-list.component.ts b/admin-ui/src/app/catalog/components/product-list/product-list.component.ts index 5642e6ac1e..55e2277c33 100644 --- a/admin-ui/src/app/catalog/components/product-list/product-list.component.ts +++ b/admin-ui/src/app/catalog/components/product-list/product-list.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { EMPTY, Observable } from 'rxjs'; -import { delay, filter, map, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators'; +import { delay, map, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators'; import { BaseListComponent } from '../../../common/base-list.component'; -import { SearchInput, SearchProducts } from '../../../common/generated-types'; +import { JobState, SearchInput, SearchProducts } from '../../../common/generated-types'; import { _ } from '../../../core/providers/i18n/mark-for-extraction'; +import { JobQueueService } from '../../../core/providers/job-queue/job-queue.service'; import { NotificationService } from '../../../core/providers/notification/notification.service'; import { DataService } from '../../../data/providers/data.service'; import { ModalService } from '../../../shared/providers/modal/modal.service'; @@ -28,6 +29,7 @@ export class ProductListComponent private dataService: DataService, private modalService: ModalService, private notificationService: NotificationService, + private jobQueueService: JobQueueService, router: Router, route: ActivatedRoute, ) { @@ -91,16 +93,19 @@ export class ProductListComponent rebuildSearchIndex() { this.dataService.product.reindex().subscribe(({ reindex }) => { - if (reindex.success) { - const time = new Intl.NumberFormat().format(reindex.timeTaken); - this.notificationService.success(_('catalog.reindex-successful'), { - count: reindex.indexedItemCount, - time, - }); - this.refresh(); - } else { - this.notificationService.error(_('catalog.reindex-error')); - } + this.notificationService.info(_('catalog.reindexing')); + this.jobQueueService.addJob(reindex.id, job => { + if (job.state === JobState.COMPLETED) { + const time = new Intl.NumberFormat().format(job.duration || 0); + this.notificationService.success(_('catalog.reindex-successful'), { + count: job.result.indexedItemCount, + time, + }); + this.refresh(); + } else { + this.notificationService.error(_('catalog.reindex-error')); + } + }); }); } diff --git a/admin-ui/src/app/common/generated-types.ts b/admin-ui/src/app/common/generated-types.ts index fb562b60ca..57ee2b1c16 100644 --- a/admin-ui/src/app/common/generated-types.ts +++ b/admin-ui/src/app/common/generated-types.ts @@ -1022,6 +1022,29 @@ export type ImportInfo = { imported: Scalars['Int'], }; +export type JobInfo = { + id: Scalars['String'], + name: Scalars['String'], + state: JobState, + progress: Scalars['Float'], + result?: Maybe, + started?: Maybe, + ended?: Maybe, + duration?: Maybe, +}; + +export type JobListInput = { + state?: Maybe, + ids?: Maybe>, +}; + +export enum JobState { + PENDING = 'PENDING', + RUNNING = 'RUNNING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED' +} + /** ISO 639-1 language code */ export enum LanguageCode { @@ -1472,7 +1495,7 @@ export type Mutation = { createProductOptionGroup: ProductOptionGroup, /** Update an existing ProductOptionGroup */ updateProductOptionGroup: ProductOptionGroup, - reindex: SearchReindexResponse, + reindex: JobInfo, /** Create a new Product */ createProduct: Product, /** Update an existing Product */ @@ -2316,6 +2339,8 @@ export type Query = { globalSettings: GlobalSettings, order?: Maybe, orders: OrderList, + job?: Maybe, + jobs: Array, paymentMethods: PaymentMethodList, paymentMethod?: Maybe, productOptionGroups: Array, @@ -2430,6 +2455,16 @@ export type QueryOrdersArgs = { }; +export type QueryJobArgs = { + jobId: Scalars['String'] +}; + + +export type QueryJobsArgs = { + input?: Maybe +}; + + export type QueryPaymentMethodsArgs = { options?: Maybe }; @@ -3412,11 +3447,6 @@ export type SearchProductsQueryVariables = { export type SearchProductsQuery = ({ __typename?: 'Query' } & { search: ({ __typename?: 'SearchResponse' } & Pick & { items: Array<({ __typename?: 'SearchResult' } & Pick)>, facetValues: Array<({ __typename?: 'FacetValueResult' } & Pick & { facetValue: ({ __typename?: 'FacetValue' } & Pick & { facet: ({ __typename?: 'Facet' } & Pick) }) })> }) }); -export type ReindexMutationVariables = {}; - - -export type ReindexMutation = ({ __typename?: 'Mutation' } & { reindex: ({ __typename?: 'SearchReindexResponse' } & Pick) }); - export type ConfigurableOperationFragment = ({ __typename?: 'ConfigurableOperation' } & Pick & { args: Array<({ __typename?: 'ConfigArg' } & Pick)> }); export type PromotionFragment = ({ __typename?: 'Promotion' } & Pick & { conditions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)>, actions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)> }); @@ -3673,6 +3703,27 @@ export type GetServerConfigQueryVariables = {}; export type GetServerConfigQuery = ({ __typename?: 'Query' } & { globalSettings: ({ __typename?: 'GlobalSettings' } & { serverConfig: ({ __typename?: 'ServerConfig' } & Pick) }) }); +export type JobInfoFragment = ({ __typename?: 'JobInfo' } & Pick); + +export type GetJobInfoQueryVariables = { + id: Scalars['String'] +}; + + +export type GetJobInfoQuery = ({ __typename?: 'Query' } & { job: Maybe<({ __typename?: 'JobInfo' } & JobInfoFragment)> }); + +export type GetAllJobsQueryVariables = { + input?: Maybe +}; + + +export type GetAllJobsQuery = ({ __typename?: 'Query' } & { jobs: Array<({ __typename?: 'JobInfo' } & JobInfoFragment)> }); + +export type ReindexMutationVariables = {}; + + +export type ReindexMutation = ({ __typename?: 'Mutation' } & { reindex: ({ __typename?: 'JobInfo' } & JobInfoFragment) }); + export type ShippingMethodFragment = ({ __typename?: 'ShippingMethod' } & Pick & { checker: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment), calculator: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment) }); export type GetShippingMethodListQueryVariables = { @@ -4179,12 +4230,6 @@ export namespace SearchProducts { export type Facet = (NonNullable)['facetValue']['facet']; } -export namespace Reindex { - export type Variables = ReindexMutationVariables; - export type Mutation = ReindexMutation; - export type Reindex = ReindexMutation['reindex']; -} - export namespace ConfigurableOperation { export type Fragment = ConfigurableOperationFragment; export type Args = (NonNullable); @@ -4457,6 +4502,28 @@ export namespace GetServerConfig { export type ServerConfig = GetServerConfigQuery['globalSettings']['serverConfig']; } +export namespace JobInfo { + export type Fragment = JobInfoFragment; +} + +export namespace GetJobInfo { + export type Variables = GetJobInfoQueryVariables; + export type Query = GetJobInfoQuery; + export type Job = JobInfoFragment; +} + +export namespace GetAllJobs { + export type Variables = GetAllJobsQueryVariables; + export type Query = GetAllJobsQuery; + export type Jobs = JobInfoFragment; +} + +export namespace Reindex { + export type Variables = ReindexMutationVariables; + export type Mutation = ReindexMutation; + export type Reindex = JobInfoFragment; +} + export namespace ShippingMethod { export type Fragment = ShippingMethodFragment; export type Checker = ConfigurableOperationFragment; diff --git a/admin-ui/src/app/core/components/job-list/job-list.component.html b/admin-ui/src/app/core/components/job-list/job-list.component.html new file mode 100644 index 0000000000..b317365ed0 --- /dev/null +++ b/admin-ui/src/app/core/components/job-list/job-list.component.html @@ -0,0 +1,20 @@ + + + +
+ {{ getJobName(job) | translate }} +
+ + {{ job.progress }}% +
+
+
+
diff --git a/admin-ui/src/app/core/components/job-list/job-list.component.scss b/admin-ui/src/app/core/components/job-list/job-list.component.scss new file mode 100644 index 0000000000..9d6cd95c4f --- /dev/null +++ b/admin-ui/src/app/core/components/job-list/job-list.component.scss @@ -0,0 +1,25 @@ +@import "variables"; + +:host { + position: fixed; + bottom: 12px; + left: 12px; + z-index: 5; +} + +.job-button { + background-color: $color-grey-200; + box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 0.2); + &.hidden { + display: none; + } +} + +.job-row { + padding: 0 60px 0 12px; + width: 90vw; + max-width: 360px; + @media screen and (min-width: $breakpoint-small){ + max-width: 400px; + } +} diff --git a/admin-ui/src/app/core/components/job-list/job-list.component.ts b/admin-ui/src/app/core/components/job-list/job-list.component.ts new file mode 100644 index 0000000000..6f89687208 --- /dev/null +++ b/admin-ui/src/app/core/components/job-list/job-list.component.ts @@ -0,0 +1,35 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { JobInfoFragment } from '../../../common/generated-types'; +import { _ } from '../../providers/i18n/mark-for-extraction'; +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/admin-ui/src/app/core/components/main-nav/main-nav.component.html b/admin-ui/src/app/core/components/main-nav/main-nav.component.html index 0026e35f50..237e7feb84 100644 --- a/admin-ui/src/app/core/components/main-nav/main-nav.component.html +++ b/admin-ui/src/app/core/components/main-nav/main-nav.component.html @@ -140,6 +140,7 @@
  • + í + diff --git a/admin-ui/src/app/core/core.module.ts b/admin-ui/src/app/core/core.module.ts index d6b4a21cbf..96a67ec33e 100644 --- a/admin-ui/src/app/core/core.module.ts +++ b/admin-ui/src/app/core/core.module.ts @@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module'; import { AppShellComponent } from './components/app-shell/app-shell.component'; import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component'; +import { JobListComponent } from './components/job-list/job-list.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'; @@ -14,6 +15,7 @@ import { UserMenuComponent } from './components/user-menu/user-menu.component'; import { AuthService } from './providers/auth/auth.service'; import { AuthGuard } from './providers/guard/auth.guard'; import { I18nService } from './providers/i18n/i18n.service'; +import { JobQueueService } from './providers/job-queue/job-queue.service'; import { LocalStorageService } from './providers/local-storage/local-storage.service'; import { NotificationService } from './providers/notification/notification.service'; import { OverlayHostService } from './providers/overlay-host/overlay-host.service'; @@ -28,6 +30,7 @@ import { OverlayHostService } from './providers/overlay-host/overlay-host.servic I18nService, OverlayHostService, NotificationService, + JobQueueService, ], declarations: [ AppShellComponent, @@ -37,6 +40,7 @@ import { OverlayHostService } from './providers/overlay-host/overlay-host.servic OverlayHostComponent, NotificationComponent, UiLanguageSwitcherComponent, + JobListComponent, ], entryComponents: [NotificationComponent], }) diff --git a/admin-ui/src/app/core/providers/job-queue/job-queue.service.ts b/admin-ui/src/app/core/providers/job-queue/job-queue.service.ts new file mode 100644 index 0000000000..ca835338c1 --- /dev/null +++ b/admin-ui/src/app/core/providers/job-queue/job-queue.service.ts @@ -0,0 +1,86 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { combineLatest, interval, Observable, Subject, Subscription } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + map, + scan, + shareReplay, + throttle, + throttleTime, +} from 'rxjs/operators'; +import { assertNever } from 'shared/shared-utils'; + +import { GetJobInfo, JobInfoFragment, JobState } from '../../../common/generated-types'; +import { DataService } from '../../../data/providers/data.service'; + +@Injectable() +export class JobQueueService implements OnDestroy { + activeJobs$: Observable; + + private updateJob$ = new Subject(); + private onCompleteHandlers = new Map void>(); + private readonly subscription: Subscription; + + constructor(private dataService: DataService) { + const initialJobList$ = this.dataService.settings + .getRunningJobs() + .single$.subscribe(data => data.jobs.forEach(job => this.updateJob$.next(job))); + + this.activeJobs$ = this.updateJob$.pipe( + scan>( + (jobMap, job) => this.handleJob(jobMap, job), + new Map(), + ), + map(jobMap => Array.from(jobMap.values())), + debounceTime(500), + shareReplay(1), + ); + + this.subscription = combineLatest(this.activeJobs$, interval(5000)) + .pipe(throttleTime(5000)) + .subscribe(([jobs]) => { + this.dataService.settings.pollJobs(jobs.map(j => j.id)).single$.subscribe(data => { + data.jobs.forEach(job => { + this.updateJob$.next(job); + }); + }); + }); + } + + ngOnDestroy(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + addJob(jobId: string, onComplete?: (job: JobInfoFragment) => void) { + this.dataService.settings.getJob(jobId).single$.subscribe(({ job }) => { + if (job) { + this.updateJob$.next(job); + if (onComplete) { + this.onCompleteHandlers.set(jobId, onComplete); + } + } + }); + } + + private handleJob(jobMap: Map, job: JobInfoFragment) { + switch (job.state) { + case JobState.RUNNING: + case JobState.PENDING: + jobMap.set(job.id, job); + break; + case JobState.COMPLETED: + case JobState.FAILED: + jobMap.delete(job.id); + const handler = this.onCompleteHandlers.get(job.id); + if (handler) { + handler(job); + this.onCompleteHandlers.delete(job.id); + } + break; + } + return jobMap; + } +} diff --git a/admin-ui/src/app/data/definitions/product-definitions.ts b/admin-ui/src/app/data/definitions/product-definitions.ts index 1b5fcb780c..b7c4df44dd 100644 --- a/admin-ui/src/app/data/definitions/product-definitions.ts +++ b/admin-ui/src/app/data/definitions/product-definitions.ts @@ -319,13 +319,3 @@ export const SEARCH_PRODUCTS = gql` } } `; - -export const REINDEX = gql` - mutation Reindex { - reindex { - indexedItemCount - success - timeTaken - } - } -`; diff --git a/admin-ui/src/app/data/definitions/settings-definitions.ts b/admin-ui/src/app/data/definitions/settings-definitions.ts index 875efdf815..405c597d6f 100644 --- a/admin-ui/src/app/data/definitions/settings-definitions.ts +++ b/admin-ui/src/app/data/definitions/settings-definitions.ts @@ -391,3 +391,41 @@ export const GET_SERVER_CONFIG = gql` } } `; + +export const JOB_INFO_FRAGMENT = gql` + fragment JobInfo on JobInfo { + id + name + state + progress + duration + result + } +`; + +export const GET_JOB_INFO = gql` + query GetJobInfo($id: String!) { + job(jobId: $id) { + ...JobInfo + } + } + ${JOB_INFO_FRAGMENT} +`; + +export const GET_ALL_JOBS = gql` + query GetAllJobs($input: JobListInput) { + jobs(input: $input) { + ...JobInfo + } + } + ${JOB_INFO_FRAGMENT} +`; + +export const REINDEX = gql` + mutation Reindex { + reindex { + ...JobInfo + } + } + ${JOB_INFO_FRAGMENT} +`; diff --git a/admin-ui/src/app/data/providers/product-data.service.ts b/admin-ui/src/app/data/providers/product-data.service.ts index 491da8ac6b..df2dd0c538 100644 --- a/admin-ui/src/app/data/providers/product-data.service.ts +++ b/admin-ui/src/app/data/providers/product-data.service.ts @@ -34,12 +34,12 @@ import { GET_PRODUCT_LIST, GET_PRODUCT_OPTION_GROUPS, GET_PRODUCT_WITH_VARIANTS, - REINDEX, REMOVE_OPTION_GROUP_FROM_PRODUCT, SEARCH_PRODUCTS, UPDATE_PRODUCT, UPDATE_PRODUCT_VARIANTS, } from '../definitions/product-definitions'; +import { REINDEX } from '../definitions/settings-definitions'; import { BaseDataService } from './base-data.service'; diff --git a/admin-ui/src/app/data/providers/settings-data.service.ts b/admin-ui/src/app/data/providers/settings-data.service.ts index 8e6b9a6bfb..b51a77688a 100644 --- a/admin-ui/src/app/data/providers/settings-data.service.ts +++ b/admin-ui/src/app/data/providers/settings-data.service.ts @@ -15,12 +15,14 @@ import { CreateZoneInput, DeleteCountry, GetActiveChannel, + GetAllJobs, GetAvailableCountries, GetChannel, GetChannels, GetCountry, GetCountryList, GetGlobalSettings, + GetJobInfo, GetPaymentMethod, GetPaymentMethodList, GetTaxCategories, @@ -29,6 +31,7 @@ import { GetTaxRateList, GetZone, GetZones, + JobState, RemoveMembersFromZone, UpdateChannel, UpdateChannelInput, @@ -54,12 +57,14 @@ import { CREATE_ZONE, DELETE_COUNTRY, GET_ACTIVE_CHANNEL, + GET_ALL_JOBS, GET_AVAILABLE_COUNTRIES, GET_CHANNEL, GET_CHANNELS, GET_COUNTRY, GET_COUNTRY_LIST, GET_GLOBAL_SETTINGS, + GET_JOB_INFO, GET_PAYMENT_METHOD, GET_PAYMENT_METHOD_LIST, GET_TAX_CATEGORIES, @@ -288,4 +293,20 @@ export class SettingsDataService { }, ); } + + getJob(id: string) { + return this.baseDataService.query(GET_JOB_INFO, { id }); + } + + pollJobs(ids: string[]) { + return this.baseDataService.query(GET_ALL_JOBS, { + input: { ids }, + }); + } + + getRunningJobs() { + return this.baseDataService.query(GET_ALL_JOBS, { + input: { state: JobState.RUNNING }, + }); + } } diff --git a/admin-ui/src/i18n-messages/en.json b/admin-ui/src/i18n-messages/en.json index 9dcd50ea91..5d9c13a631 100644 --- a/admin-ui/src/i18n-messages/en.json +++ b/admin-ui/src/i18n-messages/en.json @@ -76,6 +76,7 @@ "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", + "reindexing": "Rebuilding search index", "remove-asset": "Remove asset", "search-asset-name": "Search assets by name", "search-for-term": "Search for term", @@ -123,6 +124,7 @@ "finish": "Finish", "guest": "Guest", "items-per-page-option": "{ count } per page", + "jobs-in-progress": "{ count } {count, plural, one {job} other {jobs}} in progress", "language": "Language", "log-out": "Log out", "login": "Log in", @@ -185,6 +187,9 @@ "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", diff --git a/admin-ui/src/styles/theme/_theme.scss b/admin-ui/src/styles/theme/_theme.scss index 8548896718..d4ad588315 100644 --- a/admin-ui/src/styles/theme/_theme.scss +++ b/admin-ui/src/styles/theme/_theme.scss @@ -56,3 +56,7 @@ table tr .dropdown-menu button.dropdown-item { color: #e12200; } } + +.cdk-overlay-container { + z-index: 1040; +}