Browse Source

feat(admin-ui): Display background jobs in UI

Relates to #111
Michael Bromley 6 years ago
parent
commit
59d8312a49

+ 17 - 12
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'));
+                }
+            });
         });
     }
 

+ 79 - 12
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<Scalars['JSON']>,
+  started?: Maybe<Scalars['DateTime']>,
+  ended?: Maybe<Scalars['DateTime']>,
+  duration?: Maybe<Scalars['Int']>,
+};
+
+export type JobListInput = {
+  state?: Maybe<JobState>,
+  ids?: Maybe<Array<Scalars['String']>>,
+};
+
+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<Order>,
   orders: OrderList,
+  job?: Maybe<JobInfo>,
+  jobs: Array<JobInfo>,
   paymentMethods: PaymentMethodList,
   paymentMethod?: Maybe<PaymentMethod>,
   productOptionGroups: Array<ProductOptionGroup>,
@@ -2430,6 +2455,16 @@ export type QueryOrdersArgs = {
 };
 
 
+export type QueryJobArgs = {
+  jobId: Scalars['String']
+};
+
+
+export type QueryJobsArgs = {
+  input?: Maybe<JobListInput>
+};
+
+
 export type QueryPaymentMethodsArgs = {
   options?: Maybe<PaymentMethodListOptions>
 };
@@ -3412,11 +3447,6 @@ export type SearchProductsQueryVariables = {
 
 export type SearchProductsQuery = ({ __typename?: 'Query' } & { search: ({ __typename?: 'SearchResponse' } & Pick<SearchResponse, 'totalItems'> & { items: Array<({ __typename?: 'SearchResult' } & Pick<SearchResult, 'enabled' | 'productId' | 'productName' | 'productPreview' | 'productVariantId' | 'productVariantName' | 'productVariantPreview' | 'sku'>)>, facetValues: Array<({ __typename?: 'FacetValueResult' } & Pick<FacetValueResult, 'count'> & { facetValue: ({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) }) })> }) });
 
-export type ReindexMutationVariables = {};
-
-
-export type ReindexMutation = ({ __typename?: 'Mutation' } & { reindex: ({ __typename?: 'SearchReindexResponse' } & Pick<SearchReindexResponse, 'indexedItemCount' | 'success' | 'timeTaken'>) });
-
 export type ConfigurableOperationFragment = ({ __typename?: 'ConfigurableOperation' } & Pick<ConfigurableOperation, 'code' | 'description'> & { args: Array<({ __typename?: 'ConfigArg' } & Pick<ConfigArg, 'name' | 'type' | 'value'>)> });
 
 export type PromotionFragment = ({ __typename?: 'Promotion' } & Pick<Promotion, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled'> & { 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<ServerConfig, 'customFields'>) }) });
 
+export type JobInfoFragment = ({ __typename?: 'JobInfo' } & Pick<JobInfo, 'id' | 'name' | 'state' | 'progress' | 'duration' | 'result'>);
+
+export type GetJobInfoQueryVariables = {
+  id: Scalars['String']
+};
+
+
+export type GetJobInfoQuery = ({ __typename?: 'Query' } & { job: Maybe<({ __typename?: 'JobInfo' } & JobInfoFragment)> });
+
+export type GetAllJobsQueryVariables = {
+  input?: Maybe<JobListInput>
+};
+
+
+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<ShippingMethod, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'description'> & { checker: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment), calculator: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment) });
 
 export type GetShippingMethodListQueryVariables = {
@@ -4179,12 +4230,6 @@ export namespace SearchProducts {
   export type Facet = (NonNullable<SearchProductsQuery['search']['facetValues'][0]>)['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<ConfigurableOperationFragment['args'][0]>);
@@ -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;

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

@@ -0,0 +1,20 @@
+<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>

+ 25 - 0
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;
+    }
+}

+ 35 - 0
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<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;
+    }
+}

+ 4 - 0
admin-ui/src/app/core/components/main-nav/main-nav.component.html

@@ -140,6 +140,7 @@
                     </a>
                 </li>
                 <li>
+                    í
                     <a
                         class="nav-link"
                         [routerLink]="['/settings', 'global-settings']"
@@ -151,5 +152,8 @@
                 </li>
             </ul>
         </section>
+        <section class="nav-group">
+            <vdr-job-list></vdr-job-list>
+        </section>
     </section>
 </nav>

+ 4 - 0
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],
 })

+ 86 - 0
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<JobInfoFragment[]>;
+
+    private updateJob$ = new Subject<JobInfoFragment>();
+    private onCompleteHandlers = new Map<string, (job: JobInfoFragment) => 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<JobInfoFragment, Map<string, JobInfoFragment>>(
+                (jobMap, job) => this.handleJob(jobMap, job),
+                new Map<string, JobInfoFragment>(),
+            ),
+            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<string, JobInfoFragment>, 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;
+    }
+}

+ 0 - 10
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
-        }
-    }
-`;

+ 38 - 0
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}
+`;

+ 1 - 1
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';
 

+ 21 - 0
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<GetJobInfo.Query, GetJobInfo.Variables>(GET_JOB_INFO, { id });
+    }
+
+    pollJobs(ids: string[]) {
+        return this.baseDataService.query<GetAllJobs.Query, GetAllJobs.Variables>(GET_ALL_JOBS, {
+            input: { ids },
+        });
+    }
+
+    getRunningJobs() {
+        return this.baseDataService.query<GetAllJobs.Query, GetAllJobs.Variables>(GET_ALL_JOBS, {
+            input: { state: JobState.RUNNING },
+        });
+    }
 }

+ 5 - 0
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",

+ 4 - 0
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;
+}