diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/base-grid/_data-jobs-base-grid.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/base-grid/_data-jobs-base-grid.component.scss new file mode 100644 index 0000000000..996efccbfe --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/base-grid/_data-jobs-base-grid.component.scss @@ -0,0 +1,119 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +:host { + .label-link { + cursor: pointer; + } + + ::ng-deep { + lib-grid-action { + display: flex; + flex-direction: column; + + lib-quick-filters { + clr-icon { + margin-left: -5px; + margin-right: 3px; + margin-top: 1px; + + &.status-icon-enabled { + color: #5aa220; + } + + &.status-icon-disabled { + color: var(--clr-color-neutral-600); + } + } + } + } + + clr-datagrid { + height: 100%; + + .datagrid-column, + .datagrid-cell { + &.column__min-width--xs { + min-width: 3.25rem; + } + + &.column__min-width--s { + min-width: 4.25rem; + } + + &.column__min-width--m { + min-width: 4.8rem; + } + + &.column__min-width--l { + min-width: 5.5rem; + } + + &.column__min-width--xl { + min-width: 6.5rem; + } + + &.column__max-width--xs { + max-width: 5rem; + } + + &.column__max-width--s { + max-width: 6rem; + } + + &.column__max-width--m { + max-width: 7rem; + } + + &.column__max-width--l { + max-width: 8.5rem; + } + + &.column__max-width--xl { + max-width: 10rem; + } + + &.jobs-list__column-opener { + min-width: 75px; + width: 75px; + } + } + + .datagrid-outer-wrapper { + .datagrid-inner-wrapper { + .datagrid { + flex-basis: 0; + margin-top: 0; + } + + .datagrid-spinner { + top: 0; + height: 100%; + } + } + } + } + } + + .grid-container { + height: 100%; + display: flex; + flex-direction: column; + + .container { + height: 100%; + } + } + + .grid-container-compact { + height: auto; + display: flex; + flex-direction: column; + + .container-compact { + height: 50vh; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/base-grid/data-jobs-base-grid.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/base-grid/data-jobs-base-grid.component.ts new file mode 100644 index 0000000000..e6142b05ee --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/base-grid/data-jobs-base-grid.component.ts @@ -0,0 +1,732 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention,@angular-eslint/directive-class-suffix */ + +import { Directive, ElementRef, Input, OnInit } from '@angular/core'; +import { Location } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, take } from 'rxjs/operators'; + +import { ClrDatagridSortOrder, ClrDatagridStateInterface } from '@clr/angular'; + +import { + ApiPredicate, + ASC, + CollectionsUtil, + ComponentModel, + ComponentService, + DESC, + ErrorHandlerService, + NavigationService, + OnTaurusModelChange, + OnTaurusModelError, + OnTaurusModelInit, + OnTaurusModelInitialLoad, + OnTaurusModelLoad, + RouterService, + RouterState, + RouteState, + TaurusBaseComponent, + URLStateManager +} from '@vdk/shared'; + +import { ErrorUtil } from '../../shared/utils'; + +import { QuickFilters } from '../../shared/components'; + +import { + DataJob, + DataJobExecutionStatus, + DataJobStatus, + DataPipelinesConfig, + DataPipelinesRestoreUI, + DisplayMode, + JOBS_DATA_KEY +} from '../../model'; +import { DataJobsApiService, DataJobsService } from '../../services'; + +export const QUERY_PARAM_SEARCH = 'search'; + +export type ClrGridUIState = { + totalItems: number; + lastPage: number; + pageSize: number; + filter: { [key: string]: string }; + sort: { [key: string]: ClrDatagridSortOrder }; +}; + +export type UIElementOffset = { x: number; y: number }; + +export type DataJobsLocalStorageUserConfig = { + hiddenColumns: { [columnName: string]: boolean }; +}; + +@Directive() +export abstract class DataJobsBaseGridComponent + extends TaurusBaseComponent + implements OnInit, OnTaurusModelInit, OnTaurusModelInitialLoad, OnTaurusModelLoad, OnTaurusModelChange, OnTaurusModelError { + + static readonly UI_KEY_PAGE_OFFSET = 'pageOffset'; + static readonly UI_KEY_GRID_OFFSET = 'gridOffset'; + static readonly UI_KEY_GRID_UI_STATE = 'gridUIState'; + + static readonly CONTENT_AREA_SELECTOR = '.content-area'; + static readonly DATA_GRID_SELECTOR = '.datagrid'; + + @Input() urlUpdateStrategy: 'updateLocation' | 'updateRouter' = 'updateRouter'; + @Input() searchParam: string = QUERY_PARAM_SEARCH; + + @Input() set urlStateManager(value: URLStateManager) { + if (value) { + this._urlStateManager = value; + this._isUrlStateManagerExternalDependency = true; + } + } + + get urlStateManager(): URLStateManager { + return this._urlStateManager; + } + + override model: ComponentModel; + + teamNameFilter: string; + displayMode = DisplayMode.STANDARD; + + filterByTeamName = false; + + searchQueryValue = ''; + selectedJob: DataJob; + gridState: ClrDatagridStateInterface; + loading = false; + + dataJobs: DataJob[] = []; + totalJobs = 0; + loadDataDebouncer = new Subject<'normal' | 'forced'>(); + + deploymentStatuses = [ + DataJobStatus.ENABLED, + DataJobStatus.DISABLED, + DataJobStatus.NOT_DEPLOYED + ]; + executionStatuses = [ + DataJobExecutionStatus.SUCCEEDED, + DataJobExecutionStatus.PLATFORM_ERROR, + DataJobExecutionStatus.USER_ERROR, + DataJobExecutionStatus.SKIPPED, + DataJobExecutionStatus.CANCELLED + ]; + + clrGridCurrentPage = 1; + clrGridUIState: ClrGridUIState; + clrGridDefaultFilter: ClrGridUIState['filter']; + clrGridDefaultSort: ClrGridUIState['sort']; + + quickFilters: QuickFilters; + quickFiltersDefaultActiveIndex: number; + + dataJobStatus = DataJobStatus; + + protected restoreUIStateInProgress = false; + protected navigationInProgress = false; + protected _urlStateManager: URLStateManager; + private _isUrlStateManagerExternalDependency = false; + + protected constructor( // NOSONAR + componentService: ComponentService, + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + protected readonly routerService: RouterService, + protected readonly dataJobsService: DataJobsService, + protected readonly dataJobsApiService: DataJobsApiService, + protected readonly errorHandlerService: ErrorHandlerService, + protected readonly location: Location, + protected readonly router: Router, + protected readonly elementRef: ElementRef, + protected readonly document: Document, + protected dataPipelinesModuleConfig: DataPipelinesConfig, + protected readonly localStorageConfigKey: string, + public localStorageUserConfig: DataJobsLocalStorageUserConfig + ) { + super(componentService, navigationService, activatedRoute); + + this._urlStateManager = new URLStateManager( + router.url.split('?')[0], + location + ); + } + + /** + * ** NgFor elements tracking function. + */ + trackByFn(index: number, dataJob: DataJob): string { + return `${ index }|${ dataJob?.config?.team }|${ dataJob?.jobName }`; + } + + resolveLogsUrl(job: DataJob): string { + if (CollectionsUtil.isNil(job) || CollectionsUtil.isArrayEmpty(job.deployments)) { + return null; + } + + if (CollectionsUtil.isArrayEmpty(job.deployments[0].executions)) { + return null; + } + + return job.deployments[0].executions[0].logsUrl; + } + + showOrHideColumnChange(columnName: string, hidden: boolean): void { + this.localStorageUserConfig.hiddenColumns[columnName] = hidden; + localStorage.setItem(this.localStorageConfigKey, JSON.stringify(this.localStorageUserConfig)); + } + + getJobStatus(job: DataJob): DataJobExecutionStatus { + if (job.deployments && job.deployments[0]?.lastExecutionStatus) { + return job.deployments[0]?.lastExecutionStatus; + } + + return null; + } + + getJobSuccessRateTitle(job: DataJob): string { + if (job.deployments) { + return `${ job.deployments[0]?.successfulExecutions } successful / ${ job.deployments[0]?.failedExecutions + + job.deployments[0]?.successfulExecutions } total`; + } + + return null; + } + + /** + * ** Callback (listener) for User search. + */ + search(value: string) { + this.searchQueryValue = value; + + this._updateUrlStateManager(); + + this.refresh(); + } + + refresh(): void { + this.loadDataWithState(null); + } + + /** + * ** Main callback (listener) for ClrGrid state mutation, like filters, sort. + */ + loadDataWithState(state: ClrDatagridStateInterface): void { + if (state != null) { + this.gridState = state; + } + + if (!this.model || this.restoreUIStateInProgress) { + return; + } + + if (this.filterByTeamName && !this.teamNameFilter) { + //While the teamNameFilter is empty, no refresh requests will be executed. + console.log('Refresh operation will be skipped. teamNameFilter is empty.'); + + return; + } + + this.loadDataDebouncer.next('normal'); + } + + isStandardDisplayMode() { + return this.displayMode === DisplayMode.STANDARD; + } + + selectionChanged(dataJob: DataJob) { + this.selectedJob = dataJob; + } + + /** + * ** Navigate to Data Job details page, while at first save Ui State of the Page. + */ + navigateToJobDetails(job?: DataJob) { + if (job) { + this.saveUIState(); + this.selectionChanged(job); + + this.dataJobsService + .notifyForTeamImplicitly(job.config?.team); + + this.navigationInProgress = true; + + this.navigateTo({ + '$.team': job.config?.team, + '$.job': job.jobName + }) + .finally(() => { + this.navigationInProgress = false; + }); + } + } + + /** + * @inheritDoc + */ + onModelInit() { + let initializationFinished = false; + + this.subscriptions.push( + this.routerService + .get() + .pipe( + distinctUntilChanged((a, b) => + a.state.absoluteConfigPath !== b.state.absoluteConfigPath || + a.state.absoluteRoutePath === b.state.absoluteRoutePath + ) + ) + .subscribe((routerState) => { + if (initializationFinished) { + this._updateUrlStateManager(routerState.state); + + return; + } + + initializationFinished = true; + + this._initUrlStateManager(routerState.state); + this._extractInitialQueryParams(routerState.state); + + if (this._doesRestoreUIStateExist()) { + if (this._shouldRestoreUIState(routerState)) { + this.restoreUIStateInProgress = true; + + const clrGridUIState = this.model.getUiState( + DataJobsBaseGridComponent.UI_KEY_GRID_UI_STATE + ); + if (clrGridUIState) { + this.clrGridUIState = clrGridUIState; + } + + this.loadDataDebouncer.next('forced'); + + return; + } else { + this._clearUiPageState(); + } + } + + if (this.gridState) { + this.refresh(); + } + }) + ); + } + + /** + * @inheritDoc + */ + onModelInitialLoad() { + this.routerService + .get() + .pipe( + take(1) + ) + .subscribe((routerState) => { + if (this._shouldRestoreUIState(routerState)) { + this.restoreUIState(); + + this.restoreUIStateInProgress = false; + } + }); + } + + /** + * @inheritDoc + */ + onModelLoad() { + this.loading = false; + } + + /** + * @inheritDoc + */ + onModelChange(model: ComponentModel) { + const componentState = model.getComponentState(); + const dataJobsData: { content: DataJob[], totalItems: number } = componentState.data.get(JOBS_DATA_KEY); + + this.dataJobs = CollectionsUtil.isArray(dataJobsData?.content) + ? [...dataJobsData?.content] + : []; + + this.clrGridUIState.totalItems = dataJobsData?.totalItems ?? 0; + } + + /** + * @inheritDoc + */ + onModelError(model?: ComponentModel) { + const error = ErrorUtil.extractError( + model.getComponentState().error + ); + + this.errorHandlerService.processError(error); + } + + /** + * @inheritDoc + */ + override ngOnInit(): void { + this._initializeQuickFilters(); + this._initializeClrGridUIState(); + + this.subscriptions.push( + this.loadDataDebouncer.pipe( + debounceTime(300) + ).subscribe((handling) => { + if (this.isLoadDataAllowed() || handling === 'forced') { + this._doLoadData(); + } + + if (this.isUrlUpdateAllowed() || handling === 'forced') { + this._doUrlUpdate(); + } + }) + ); + + super.ngOnInit(); + + this.loading = true; + + try { + this._loadLocalStorageUserConfig(); + } catch (e1) { + console.error('Failed to read config from localStorage', e1, 'Will attempt to re-create it.'); + try { + localStorage.removeItem(this.localStorageConfigKey); + this._loadLocalStorageUserConfig(); + } catch (e2) { + console.error('Was unable to re-initialize localStorage user config', e2); + } + } + } + + protected isLoadDataAllowed(): boolean { + if (!this.gridState) { + //While the gridState is empty, no refresh requests will be executed. + console.log('Load data will be skipped. gridState is empty. operation not allowed.'); + + return false; + } + + return !this.navigationInProgress; + } + + protected isUrlUpdateAllowed(): boolean { + return !this.navigationInProgress && this.urlStateManager.isQueryParamsStateMutated; + } + + protected saveUIState() { + const dataGrid = this.elementRef.nativeElement.querySelector(DataJobsBaseGridComponent.DATA_GRID_SELECTOR); + if (dataGrid) { + this.model.withUiState(DataJobsBaseGridComponent.UI_KEY_GRID_OFFSET, { + x: dataGrid.scrollLeft, + y: dataGrid.scrollTop + }); + } + + const contentArea = this.document.querySelector(DataJobsBaseGridComponent.CONTENT_AREA_SELECTOR); + if (contentArea) { + this.model.withUiState(DataJobsBaseGridComponent.UI_KEY_PAGE_OFFSET, { + x: contentArea.scrollLeft, + y: contentArea.scrollTop + }); + } + + const clrGridUIStateDeepCloned = CollectionsUtil.cloneDeep(this.clrGridUIState); + clrGridUIStateDeepCloned.pageSize = this.model + .getComponentState() + ?.page + ?.size; + clrGridUIStateDeepCloned.lastPage = this.clrGridCurrentPage; + + this.model.withUiState(DataJobsBaseGridComponent.UI_KEY_GRID_UI_STATE, clrGridUIStateDeepCloned); + + this.componentService.update(this.model.getComponentState()); + } + + protected restoreUIState() { + if (!this._doesRestoreUIStateExist()) { + return; + } + + setTimeout(() => { + const gridOffset = this.model.getUiState(DataJobsBaseGridComponent.UI_KEY_GRID_OFFSET); + const dataGrid = this.elementRef.nativeElement.querySelector(DataJobsBaseGridComponent.DATA_GRID_SELECTOR); + if (dataGrid) { + dataGrid.scrollTo(gridOffset.x, gridOffset.y); + } + + const pageOffset = this.model.getUiState(DataJobsBaseGridComponent.UI_KEY_PAGE_OFFSET); + const contentArea = this.document.querySelector(DataJobsBaseGridComponent.CONTENT_AREA_SELECTOR); + if (contentArea) { + contentArea.scrollTo(pageOffset.x, pageOffset.y); + } + + this._clearUiPageState(); + }, 25); + } + + private _shouldRestoreUIState(routerState: RouterState): boolean { + const restoreUiWhen = routerState.state.getData('restoreUiWhen'); + if (CollectionsUtil.isNil(restoreUiWhen)) { + return true; + } + + if (!CollectionsUtil.isString(restoreUiWhen.previousConfigPathLike)) { + return true; + } + + return routerState + .getPrevious() + .state + .absoluteConfigPath + .includes(restoreUiWhen.previousConfigPathLike); + } + + private _doesRestoreUIStateExist(): boolean { + return CollectionsUtil.isDefined(this.model) + && CollectionsUtil.isDefined( + this.model.getUiState(DataJobsBaseGridComponent.UI_KEY_GRID_UI_STATE) + ); + } + + private _clearUiPageState() { + this.model + .getComponentState() + .uiState + .delete(DataJobsBaseGridComponent.UI_KEY_GRID_OFFSET); + this.model + .getComponentState() + .uiState + .delete(DataJobsBaseGridComponent.UI_KEY_PAGE_OFFSET); + this.model + .getComponentState() + .uiState + .delete(DataJobsBaseGridComponent.UI_KEY_GRID_UI_STATE); + + this.componentService + .update(this.model.getComponentState()); + } + + private _doLoadData(): void { + this.selectedJob = null; + this.loading = true; + + if (this._doesRestoreUIStateExist()) { + this.clrGridCurrentPage = this.clrGridUIState.lastPage; + } else { + this.model + .withFilter(this._buildRefreshFilters()) + .withSearch(this.searchQueryValue) + .withPage(this.gridState?.page?.current, this.gridState?.page?.size); + } + + this.dataJobsService.loadJobs(this.model); + } + + private _initUrlStateManager(routeState: RouteState): void { + if (!this._isUrlStateManagerExternalDependency) { + this._urlStateManager = new URLStateManager( + routeState.absoluteRoutePath, + this.location + ); + } + } + + private _extractInitialQueryParams(routeState: RouteState): void { + const initialSearchParams = routeState.getQueryParam(this.searchParam); + + if (initialSearchParams) { + this.searchQueryValue = initialSearchParams; + + this._updateUrlStateManager(); + } else { + this.searchQueryValue = ''; + } + } + + private _updateUrlStateManager(routeState?: RouteState): void { + if (CollectionsUtil.isDefined(routeState)) { + this.urlStateManager.baseURL = routeState.absoluteRoutePath; + } + + this.urlStateManager + .setQueryParam(this.searchParam, this.searchQueryValue); + } + + private _doUrlUpdate(): void { + if (this.urlUpdateStrategy === 'updateLocation') { + this.urlStateManager.locationToURL(); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.urlStateManager + .navigateToUrl() + .then(); + } + } + + private _loadLocalStorageUserConfig() { + const userConfig = localStorage.getItem(this.localStorageConfigKey); + if (userConfig) { + let newColumnProvided = false; + const parsedUserConfig: DataJobsLocalStorageUserConfig = JSON.parse(userConfig); + + CollectionsUtil.iterateObject(this.localStorageUserConfig.hiddenColumns, (value, key) => { + if (!parsedUserConfig.hiddenColumns.hasOwnProperty(key)) { + newColumnProvided = true; + parsedUserConfig.hiddenColumns[key] = value; + } + }); + + if (newColumnProvided) { + localStorage.setItem(this.localStorageConfigKey, JSON.stringify(parsedUserConfig)); + } + + this.localStorageUserConfig = parsedUserConfig; + } else { + localStorage.setItem(this.localStorageConfigKey, JSON.stringify(this.localStorageUserConfig)); + } + } + + /** + * ** Builds refresh filters. + * + * - Convert filters from an array to map, because that's what backend-calling service is expecting + */ + private _buildRefreshFilters(): ApiPredicate[] { + const filters: ApiPredicate[] = []; + + if (this.teamNameFilter) { + filters.push({ + property: 'config.team', + pattern: this.teamNameFilter, + sort: null + }); + } + + if (this.gridState?.filters) { + for (const _filter of this.gridState.filters) { + const { property, value } = _filter as { property: string; value: string }; + + filters.push({ + property, + pattern: this._getFilterPattern(property, value), + sort: null + }); + } + } + + if (this.gridState?.sort) { + const direction = this.gridState.sort.reverse + ? DESC + : ASC; + + filters.push({ + property: this.gridState.sort.by as string, + pattern: null, + sort: direction + }); + } + + return filters; + } + + private _getFilterPattern(propertyName: string, value: string) { + // TODO: Remove this, once the Backend support % filterting for all the properties + // TODO: Once jobName get the same handling as config.team, add case proper case + switch (propertyName) { + case 'config.team': + return `%${ value }%`; + case 'deployments.enabled': + return `${ value }`.toLowerCase().replace(' ', '_'); + case 'deployments.lastExecutionStatus': + return `${ value }`.toLowerCase(); + default: + return `${ value }`; + } + } + + private _initializeQuickFilters(): void { + const activateFilter = (status: DataJobStatus) => () => { + this.clrGridUIState.filter['deployments.enabled'] = status; + }; + + const deactivateFilter = () => { + delete this.clrGridUIState.filter['deployments.enabled']; + }; + + const filters: QuickFilters = [ + { + label: 'All', + suppressCancel: true, + onActivate: () => { + this.clrGridUIState.filter = {}; + } + }, + { + label: 'Enabled', + onActivate: activateFilter(DataJobStatus.ENABLED), + onDeactivate: deactivateFilter, + icon: { + title: 'Enabled - This job is deployed and executed by schedule', + class: 'is-solid status-icon-enabled', + shape: 'check-circle', + size: 20 + } + }, + { + label: 'Disabled', + onActivate: activateFilter(DataJobStatus.DISABLED), + onDeactivate: deactivateFilter, + icon: { + title: 'Disabled - This job is deployed but not executing by schedule', + class: 'is-solid status-icon-disabled', + shape: 'times-circle', + size: 15 + } + }, + { + label: 'Not Deployed', + onActivate: activateFilter(DataJobStatus.NOT_DEPLOYED), + onDeactivate: deactivateFilter, + icon: { + title: 'Not Deployed - This job is created but still not deployed', + shape: 'circle', + size: 15 + } + } + ]; + + if (CollectionsUtil.isNumber(this.quickFiltersDefaultActiveIndex) && + CollectionsUtil.isDefined(filters[this.quickFiltersDefaultActiveIndex])) { + + filters[this.quickFiltersDefaultActiveIndex].active = true; + } + + this.quickFilters = filters; + } + + private _initializeClrGridUIState(): void { + this.clrGridUIState = { + totalItems: 0, + lastPage: 1, + pageSize: 25, + filter: { + ...(this.clrGridDefaultFilter ?? {}) + }, + sort: { + ...(this.clrGridDefaultSort ?? {}) + } + }; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.html new file mode 100644 index 0000000000..4a998972e0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.html @@ -0,0 +1,171 @@ + + + + +
+
+ +
+
+

+ + + + Data Job: {{jobName}} +

+
+ +
+

+ + + + Data Job: {{jobName}} +

+
+
+
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ +
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.scss new file mode 100644 index 0000000000..c343137cb0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.scss @@ -0,0 +1,121 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@mixin fill-parent-content() { + display: flex; + flex-direction: column; + flex: 1 1 auto; +} + +hr { + border: 0; + height: 1px; + background: #8f9ba3; +} + +.label-link { + cursor: pointer; +} + +.label-link-suppress-decoration { + &:hover { + text-decoration: none; + } +} + +.status-icon-enabled { + color: hsl(93, 67%, 38%); +} + +.clr-col-6 { + border-right: 1px solid #999999; +} + +.loading-spinner { + margin-left: 45%; + margin-top: 100px; +} + +:host { + @include fill-parent-content(); + height: 100%; +} + +.data-pipelines-job__page { + @include fill-parent-content(); + + .data-pipelines-job__actions { + display: inline-flex; + margin-top: 0.8rem; + + .data-pipelines-job__actions-right { + margin-left: auto; + + clr-dropdown { + margin-top: var(--clr-btn-vertical-margin, 0.3rem); + margin-bottom: var(--clr-btn-vertical-margin, 0.3rem); + margin-left: 0; + } + + .data-pipelines-job__action-dropdown-trigger { + padding-right: 1.5rem; + + clr-icon { + transform: translateY(-50%) rotate(180deg); + top: 0.85rem; + } + } + } + + &.data-pipelines-job__actions--margin-0 { + margin: 0; + } + + .data-pipelines-job__navigate-back { + margin-right: 10px; + margin-top: -1px; + + .redo-icon { + color: var(--clr-link-color, #0072a3); + height: 25px; + width: 25px; + } + } + + .data-pipelines-job__info { + display: inline-flex; + + span { + margin-right: 0.5rem; + } + } + + .page-title { + span { + font-size: x-large; + } + } + } + + .data-pipelines-job__tabs-navigation { + margin-top: 7px; + + .job-details__promotion-icon { + margin-top: -0.75rem; + margin-left: 0.25rem; + } + + .alpha-icon { + height: 15px; + width: 30px; + margin-top: -0.75rem; + font-size: 9px; + } + } + + .data-pipelines-job__router-outlet-container { + @include fill-parent-content(); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.spec.ts new file mode 100644 index 0000000000..7458635d2a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.spec.ts @@ -0,0 +1,296 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BehaviorSubject, of, Subject } from 'rxjs'; + +import { + ComponentModel, + ComponentService, + ComponentStateImpl, + createRouteSnapshot, + ErrorHandlerService, + NavigationService, + RouterService, + RouterState, + RouteState, + ToastService +} from '@vdk/shared'; + +import { + DATA_PIPELINES_CONFIGS, + DataJobDeploymentDetails, + DataJobDeploymentStatus, + DataJobDetails, + DataJobExecution, + DataJobExecutionStatus, + DataJobExecutionType +} from '../../model'; + +import { DataJobsApiService, DataJobsService } from '../../services'; + +import { DataJobPageComponent } from './data-job-page.component'; + +describe('DataJobsDetailsComponent', () => { + let componentServiceStub: jasmine.SpyObj; + let navigationServiceStub: jasmine.SpyObj; + let routerServiceStub: jasmine.SpyObj; + let toastServiceStub: jasmine.SpyObj; + let dataJobsApiServiceStub: jasmine.SpyObj; + let dataJobsServiceStub: jasmine.SpyObj; + let errorHandlerServiceStub: jasmine.SpyObj; + + let componentModelStub: ComponentModel; + let component: DataJobPageComponent; + let fixture: ComponentFixture; + + const TEST_JOB_EXECUTION = { + id: 'id002', + jobName: 'job002', + status: DataJobExecutionStatus.SUBMITTED, + startTime: new Date().toISOString(), + startedBy: 'aUserov', + endTime: new Date().toISOString(), + type: DataJobExecutionType.MANUAL, + opId: 'op002', + message: 'message001', + deployment: { + id: 'id002', + enabled: true, + jobVersion: '002', + mode: 'test_mode', + vdkVersion: '002', + resources: { + memoryLimit: 1000, + memoryRequest: 1000, + cpuLimit: 0.5, + cpuRequest: 0.5 + }, + executions: [], + deployedDate: '2020-11-11T10:10:10Z', + deployedBy: 'pmitev', + status: DataJobDeploymentStatus.SUCCESS + } + } as DataJobExecution; + + const TEST_JOB_DEPLOYMENT = { + id: 'id002', + enabled: true, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + job_version: '002', + mode: 'test_mode', + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + vdk_version: '002', + endTime: new Date(), + opId: 'op002', + message: 'message002', + /* eslint-disable @typescript-eslint/naming-convention */ + deployed_date: '2020-11-11T10:10:10Z', + deployed_by: 'pmitev', + resources: { + memory_limit: 1000, + memory_request: 1000, + cpu_limit: 0.5, + cpu_request: 0.5 + } + /* eslint-enable @typescript-eslint/naming-convention */ + + } as DataJobDeploymentDetails; + + beforeEach(() => { + componentServiceStub = jasmine.createSpyObj('componentService', ['init', 'getModel', 'idle']); + navigationServiceStub = jasmine.createSpyObj('navigationService', ['navigate', 'navigateTo', 'navigateBack']); + routerServiceStub = jasmine.createSpyObj('routerService', ['getState']); + toastServiceStub = jasmine.createSpyObj('toastService', ['show']); + dataJobsApiServiceStub = jasmine.createSpyObj('dataJobsApiService', [ + 'getJobDetails', + 'getJobExecutions', + 'getJobDeployments', + 'removeJob', + 'downloadFile', + 'executeDataJob', + 'getJob' + ]); + dataJobsServiceStub = jasmine.createSpyObj('dataJobsService', [ + 'loadJobs', + 'notifyForRunningJobExecutionId', + 'notifyForJobExecutions', + 'notifyForTeamImplicitly', + 'getNotifiedForRunningJobExecutionId', + 'getNotifiedForJobExecutions', + 'getNotifiedForTeamImplicitly' + ]); + errorHandlerServiceStub = jasmine.createSpyObj('errorHandlerService', [ + 'processError', + 'handleError' + ]); + + const activatedRouteStub = () => ({ + snapshot: createRouteSnapshot({ + data: { + activateSubpageNavigation: true + } + }) + }); + + dataJobsApiServiceStub.executeDataJob.and.returnValue(new Subject()); + dataJobsApiServiceStub.getJob.and.returnValue(new Subject()); + dataJobsApiServiceStub.getJobDetails.and.returnValue(new Subject()); + dataJobsApiServiceStub.getJobExecutions.and.returnValue(of({ content: [TEST_JOB_EXECUTION], totalItems: 1, totalPages: 1 })); + dataJobsApiServiceStub.getJobDeployments.and.returnValue(of([TEST_JOB_DEPLOYMENT])); + dataJobsApiServiceStub.removeJob.and.returnValue(new Subject()); + dataJobsApiServiceStub.downloadFile.and.returnValue(new Subject()); + + dataJobsServiceStub.getNotifiedForRunningJobExecutionId.and.returnValue(new Subject()); + dataJobsServiceStub.getNotifiedForJobExecutions.and.returnValue(new Subject()); + dataJobsServiceStub.getNotifiedForTeamImplicitly.and.returnValue(new BehaviorSubject('taurus')); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]) + ], + schemas: [NO_ERRORS_SCHEMA], + declarations: [DataJobPageComponent], + providers: [ + { provide: ComponentService, useValue: componentServiceStub }, + { provide: NavigationService, useValue: navigationServiceStub }, + { provide: RouterService, useValue: routerServiceStub }, + { provide: ActivatedRoute, useFactory: activatedRouteStub }, + { provide: ToastService, useValue: toastServiceStub }, + { provide: DataJobsApiService, useValue: dataJobsApiServiceStub }, + { provide: DataJobsService, useValue: dataJobsServiceStub }, + { provide: ErrorHandlerService, useValue: errorHandlerServiceStub }, + { + provide: DATA_PIPELINES_CONFIGS, + useFactory: () => ({ + defaultOwnerTeamName: 'all', + manageConfig: { + allowKeyTabDownloads: true, + allowExecuteNow: true + } + }) + } + ] + }); + + componentModelStub = ComponentModel.of( + ComponentStateImpl.of({}), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + componentServiceStub.init.and.returnValue(of(componentModelStub)); + componentServiceStub.getModel.and.returnValue(of(componentModelStub)); + routerServiceStub.getState.and.returnValue(of(RouteState.empty())); + + fixture = TestBed.createComponent(DataJobPageComponent); + component = fixture.componentInstance; + component.model = componentModelStub; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('makes expected calls', () => { + // When + component.ngOnInit(); + + // Then + expect(componentServiceStub.init).toHaveBeenCalled(); + expect(componentServiceStub.getModel).toHaveBeenCalled(); + expect(routerServiceStub.getState).toHaveBeenCalled(); + }); + }); + + describe('allowJobExecuteNow', () => { + it('returns true in case of dataPipelinesModuleConfig.manageConfig.allowExecuteNow', () => { + expect(component.isExecuteJobAllowed).toBeFalse(); + }); + }); + + describe('confirmExecuteJob', () => { + it('makes expected calls', () => { + // When + // @ts-ignore + spyOn(component, '_submitOperationStarted').and.callThrough(); + // @ts-ignore + spyOn(component, '_extractJobDeployment').and.returnValue({ id: '10' } as DataJobDetails); + + // When + component.confirmExecuteJob(); + + // Then + expect(dataJobsApiServiceStub.executeDataJob).toHaveBeenCalled(); + // @ts-ignore + expect(component._submitOperationStarted).toHaveBeenCalled(); + }); + }); + + describe('executeJob', () => { + it('sets executeNowOptions', () => { + // When + component.executeJob(); + + // Then + expect(component.executeNowOptions.message).toBeDefined(); + expect(component.executeNowOptions.infoText).toBeDefined(); + expect(component.executeNowOptions.opened).toBeTrue(); + expect(component.executeNowOptions.title).toBeDefined(); + }); + }); + + describe('downloadKey', () => { + it('makes expected calls', () => { + // Given + // @ts-ignore + spyOn(component, '_submitOperationStarted').and.callThrough(); + + // When + component.downloadJobKey(); + + // Then + // @ts-ignore + expect(component._submitOperationStarted).toHaveBeenCalled(); + expect(dataJobsApiServiceStub.downloadFile).toHaveBeenCalled(); + }); + }); + + describe('confirmRemoveJob', () => { + it('makes expected calls', () => { + // Given + // @ts-ignore + spyOn(component, '_submitOperationStarted').and.callThrough(); + + // When + component.confirmRemoveJob(); + + // Then + // @ts-ignore + expect(component._submitOperationStarted).toHaveBeenCalled(); + expect(dataJobsApiServiceStub.removeJob).toHaveBeenCalled(); + }); + }); + + describe('removeJob', () => { + it('sets deleteOptions', () => { + // When + component.removeJob(); + + // Then + expect(component.deleteOptions.message).toBeDefined(); + expect(component.deleteOptions.infoText).toBeDefined(); + expect(component.deleteOptions.showOkBtn).toBeTrue(); + expect(component.deleteOptions.cancelBtn).toBeDefined(); + expect(component.deleteOptions.opened).toBeTrue(); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.ts new file mode 100644 index 0000000000..c8559c0ac5 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/data-job-page.component.ts @@ -0,0 +1,680 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/member-ordering */ + +import { Component, Inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { interval, of, Subject, timer } from 'rxjs'; +import { catchError, filter, finalize, map, switchMap, take, takeUntil, takeWhile, tap } from 'rxjs/operators'; + +import { ClrLoadingState } from '@clr/angular'; + +import { VmwToastType } from '@vdk/shared'; + +import * as fileSaver from 'file-saver'; + +import { + ASC, + CollectionsUtil, + ComponentModel, + ComponentService, + ErrorHandlerService, + NavigationService, + OnTaurusModelError, + OnTaurusModelInit, + RouterService, + RouteState, + TaurusBaseComponent, + ToastService +} from '@vdk/shared'; + +import { DataJobUtil, ErrorUtil } from '../../shared/utils'; +import { ExtractJobStatusPipe } from '../../shared/pipes'; +import { ConfirmationModalOptions, DeleteModalOptions, ModalOptions } from '../../shared/model'; + +import { + DATA_PIPELINES_CONFIGS, + DataJobDeployment, + DataJobExecution, + DataJobExecutionDetails, + DataJobExecutions, + DataJobExecutionsPage, + DataJobStatus, + DataPipelinesConfig, + ToastDefinitions +} from '../../model'; + +import { DataJobsApiService, DataJobsService } from '../../services'; + +enum TypeButtonState { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + DOWNLOAD, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + EXECUTE, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + DELETE, + /* eslint-disabe-next-line @typescript-eslint/naming-convention */ + STOP +} + +@Component({ + selector: 'lib-data-job-page', + templateUrl: './data-job-page.component.html', + styleUrls: ['./data-job-page.component.scss'] +}) +export class DataJobPageComponent extends TaurusBaseComponent implements OnInit, OnTaurusModelInit, OnTaurusModelError { + readonly uuid = 'DataJobPageComponent'; + + teamName = ''; + jobName = ''; + isDataJobRunning = false; + cancelDataJobDisabled = false; + + queryParams: Params = {}; + + isSubpageNavigation = false; + + isJobAvailable = false; + isJobEditable = false; + + isExecuteJobAllowed = false; + isDownloadJobKeyAllowed = false; + + areJobExecutionsLoaded = false; + + loadingInProgress = false; + + jobExecutions: DataJobExecutions = []; + jobDeployments: DataJobDeployment[] = []; + + deleteButtonsState = ClrLoadingState.DEFAULT; + executeButtonsState = ClrLoadingState.DEFAULT; + downloadButtonsState = ClrLoadingState.DEFAULT; + stopButtonsState = ClrLoadingState.DEFAULT; + + deleteOptions: ModalOptions; + executeNowOptions: ModalOptions; + cancelNowOptions: ModalOptions; + + private _nonExistingJobMsgShowed = false; + + constructor(componentService: ComponentService, // NOSONAR + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + private readonly routerService: RouterService, + private readonly dataJobsService: DataJobsService, + private readonly dataJobsApiService: DataJobsApiService, + private readonly toastService: ToastService, + private readonly errorHandlerService: ErrorHandlerService, + @Inject(DATA_PIPELINES_CONFIGS) public readonly dataPipelinesModuleConfig: DataPipelinesConfig) { + super(componentService, navigationService, activatedRoute); + + this.isSubpageNavigation = !!activatedRoute.snapshot.data['activateSubpageNavigation']; + + this.deleteOptions = new DeleteModalOptions(); + this.executeNowOptions = new ConfirmationModalOptions(); + this.cancelNowOptions = new ConfirmationModalOptions(); + } + + /** + * ** Navigate back leveraging provided router config. + */ + doNavigateBack($event?: MouseEvent): void { + $event?.preventDefault(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.navigateBack({ '$.team': this.teamName }) + .then(); + } + + /** + * ** Returns if execution is in progress. + */ + isExecutionInProgress(): boolean { + return DataJobUtil.isJobRunning(this.jobExecutions); + } + + /** + * ** Show confirmation dialog for Job execution. + */ + executeJob() { + this.executeNowOptions.title = `Execute ${ this.jobName } now?`; + this.executeNowOptions.message = `Job ${ this.jobName } will be queued for execution.`; + this.executeNowOptions.infoText = `Confirming will result in immediate data job execution.`; + this.executeNowOptions.opened = true; + } + + /** + * ** On User confirm continue with Job execution. + */ + confirmExecuteJob() { + this._submitOperationStarted(TypeButtonState.EXECUTE); + + this.subscriptions.push( + this.dataJobsApiService + .executeDataJob( + this.teamName, + this.jobName, + this._extractJobDeployment()?.id + ) + .pipe( + finalize(() => { + this._submitOperationEnded(); + }) + ) + .subscribe({ + next: () => { + this.toastService.show(ToastDefinitions.successfullyRanJob(this.jobName)); + + let previousReqFinished = true; + + this.areJobExecutionsLoaded = false; + + this.subscriptions.push( + interval(1250) // Send polling request on every 1.25s until execution is accepted from backend + .pipe( + // eslint-disable-next-line rxjs/no-unsafe-takeuntil + takeUntil(timer(30000)), // Timer limit when polling to stop = 30s + filter(() => previousReqFinished), + tap(() => previousReqFinished = false), + switchMap(() => + this.dataJobsApiService.getJobExecutions( + this.teamName, + this.jobName, + true, + null, + { property: 'startTime', direction: ASC } + ).pipe( + catchError((error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error) + ); + + return of([]); + }), + finalize(() => { + previousReqFinished = true; + }) + ) + ), + map((executions: DataJobExecutionsPage) => + executions.content + ? [...executions.content] + : [] + ), + takeWhile((executions) => { + if (CollectionsUtil.isArrayEmpty(executions) || executions.length <= this.jobExecutions.length) { + return true; + } + + this.jobExecutions = executions; + + this.areJobExecutionsLoaded = true; + + const lastExecution = executions[executions.length - 1]; + if (!DataJobUtil.isJobRunningPredicate(lastExecution)) { + return true; + } + + this.dataJobsService.notifyForJobExecutions(executions); + this.dataJobsService.notifyForRunningJobExecutionId(lastExecution.id); + + return false; // Stop polling if above condition is met. + }) + ) + .subscribe() // eslint-disable-line rxjs/no-nested-subscribe + ); + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + title: (error as HttpErrorResponse)?.status === 409 + ? 'Failed, Data job is already executing' + : 'Failed to queue Data job for execution' + } + ); + } + }) + ); + } + + /** + * ** Download Job key. + */ + downloadJobKey() { + this._submitOperationStarted(TypeButtonState.DOWNLOAD); + + this.dataJobsApiService + .downloadFile( + this.teamName, + this.jobName + ) + .pipe( + finalize(() => { + this._submitOperationEnded(); + }) + ) + .subscribe({ + next: (response: Blob) => { + const blob: Blob = new Blob([response], { type: 'application/octet-stream' }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + fileSaver.saveAs(blob, `${ this.jobName }.keytab`); + + this.toastService.show({ + type: VmwToastType.INFO, + title: `Download completed`, + description: `Data job keytab "${ this.jobName }.keytab" successfully downloaded` + }); + }, + error: (error: unknown) => { + const errorDescription = (error as HttpErrorResponse)?.status === 404 + ? `Download failed. Keytab file doesn't exist for this job.` + : `Download failed. Keytab file failed to download.`; + + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + description: errorDescription + } + ); + } + }); + } + + /** + * ** Show confirmation dialog for Job Remove (Delete). + */ + removeJob() { + this.deleteOptions.message = `Job ${ this.jobName } will be deleted. + Currently executing Data Jobs will be left to finish but the credentials will be revoked.`; + this.deleteOptions.infoText = `Deleting this job means that it will be permanently removed from the system + including all its state (properties), source code and any deployments.`; + this.deleteOptions.showOkBtn = true; + this.deleteOptions.cancelBtn = 'Cancel'; + this.deleteOptions.opened = true; + } + + /** + * ** On User confirm continue with Job Remove (Delete). + */ + confirmRemoveJob() { + this._submitOperationStarted(TypeButtonState.DELETE); + + this.dataJobsApiService + .removeJob(this.teamName, this.jobName) + .pipe( + finalize(() => { + this._submitOperationEnded(); + }) + ) + .subscribe({ + next: () => { + this.toastService.show({ + type: VmwToastType.INFO, + title: `Data job delete completed`, + description: `Data job "${ this.jobName }" successfully deleted` + }); + + this.doNavigateBack(); + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + title: `Data job delete failed` + } + ); + } + }); + } + + confirmCancelDataJob() { + this._submitOperationStarted(TypeButtonState.STOP); + this.dataJobsApiService.cancelDataJobExecution(this.teamName, this.jobName, this.lastExecution()?.id) + .pipe( + finalize(() => { + this._submitOperationEnded(); + }) + ) + .subscribe({ + next: () => { + this.cancelDataJobDisabled = true; + this.toastService.show({ + type: VmwToastType.INFO, + title: `Data job execution cancellation completed`, + description: `Data job "${ this.jobName }" successfully canceled` + }); + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + title: `Data job cancellation failed` + } + ); + } + }); + } + + /** + * ** Show confirmation dialog for Job execution cancellation. + */ + cancelExecution() { + this.cancelNowOptions.title = `Cancel ${ this.lastExecution()?.id } now?`; + this.cancelNowOptions.message = `Execution ${ this.lastExecution()?.id } will be canceled.`; + this.cancelNowOptions.infoText = `Confirming will result in immediate data job execution cancellation.`; + this.cancelNowOptions.opened = true; + } + + lastExecution(): DataJobExecution { + return this.jobExecutions[this.jobExecutions.length - 1]; + } + + isJobWithRunningStatus(): boolean { + return this.lastExecution().status === 'RUNNING'; + } + + /** + * @inheritDoc + */ + onModelInit(): void { + this.routerService + .getState() + .pipe( + take(1) + ) + .subscribe((state) => this._initialize(state)); + } + + /** + * @inheritDoc + */ + onModelError(model?: ComponentModel) { + const error = ErrorUtil.extractError( + model.getComponentState().error + ); + + this.errorHandlerService.processError(error); + } + + private _initialize(state: RouteState): void { + const teamParamKey = state.getData('teamParamKey'); + this.teamName = state.getParam(teamParamKey); + + if (CollectionsUtil.isNil(teamParamKey) || CollectionsUtil.isNil(this.teamName)) { + this._subscribeForImplicitTeam(); + } + + const jobParamKey = state.getData('jobParamKey'); + this.jobName = state.getParam(jobParamKey); + + this.isJobEditable = !!state.getData('editable'); + + this.queryParams = state.queryParams; + + this.isDownloadJobKeyAllowed = this.dataPipelinesModuleConfig.manageConfig?.allowKeyTabDownloads + && this.isJobEditable; + + this._subscribeForTeamChange(state); + this._subscribeForExecutionsChange(); + this._subscribeForExecutionIdChange(); + this._loadJobDetails(); + this._loadJobExecutions(); + } + + private _subscribeForImplicitTeam(): void { + this.dataJobsService + .getNotifiedForTeamImplicitly() + .pipe( + take(1) + ) + .subscribe((teamName) => this.teamName = teamName); + } + + private _subscribeForTeamChange(state: RouteState): void { + const shouldActivateListener = !!state.getData('activateListenerForTeamChange'); + + if (shouldActivateListener && this.dataPipelinesModuleConfig?.manageConfig?.selectedTeamNameObservable) { + this.subscriptions.push( + this.dataPipelinesModuleConfig + .manageConfig + .selectedTeamNameObservable + .subscribe((newTeam) => { + if (this.teamName !== newTeam) { + this.teamName = newTeam; + + this.doNavigateBack(); + } + }) + ); + } + } + + private _subscribeForExecutionsChange(): void { + this.subscriptions.push( + this.dataJobsService + .getNotifiedForJobExecutions() + .subscribe((executions) => { + this.jobExecutions = [...executions]; + }) + ); + } + + private _subscribeForExecutionIdChange(): void { + const scheduleLastExecutionPolling = new Subject(); + + this.subscriptions.push( + scheduleLastExecutionPolling + .pipe( + switchMap((id) => + interval(5000).pipe( + switchMap(() => + this.dataJobsApiService.getJobExecution( + this.teamName, + this.jobName, + id + ).pipe( + catchError((error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error) + ); + + return of(null); + }) + ) + ), + tap((executionDetails) => this._replaceRunningExecutionAndNotify(executionDetails)), + takeWhile((executionDetails) => { + const isRunning = CollectionsUtil.isNil(executionDetails) || + DataJobUtil.isJobRunningPredicate(executionDetails); + if (!isRunning) { + this.isDataJobRunning = false; + } + return isRunning + }) + ) + ) + ) + .subscribe() + ); + + this.subscriptions.push( + this.dataJobsService + .getNotifiedForRunningJobExecutionId() + .pipe( + switchMap((executionId: string) => + this.dataJobsApiService.getJobExecution( + this.teamName, + this.jobName, + executionId + ).pipe( + map((executionDetails) => [executionId, executionDetails]), + catchError((error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error) + ); + + return of([executionId]); + }) + ) + ) + ) + .subscribe(([executionId, executionDetails]: [string, DataJobExecutionDetails]) => { + + this.isDataJobRunning = true; + this.cancelDataJobDisabled = false; + this._replaceRunningExecutionAndNotify(executionDetails); + scheduleLastExecutionPolling.next(executionId); + }) + ); + } + + private _loadJobDetails(): void { + this.subscriptions.push( + this.dataJobsApiService + .getJobDetails(this.teamName, this.jobName) + .subscribe({ + error: (error: unknown) => { + if (error instanceof HttpErrorResponse) { + if (error.status === 404) { + this._showMessageJobNotExist(); + this.doNavigateBack(); + } + + console.error('Error loading jobDetails', error); + } + } + }) + ); + this.subscriptions.push( + this.dataJobsApiService + .getJob(this.teamName, this.jobName) + .subscribe({ + next: (job) => { + if (CollectionsUtil.isDefined(job)) { + this.isJobAvailable = true; + + this.jobDeployments = job.deployments; + this.isExecuteJobAllowed = ExtractJobStatusPipe + .transform(this.jobDeployments) !== DataJobStatus.NOT_DEPLOYED; + + return; + } + + this._showMessageJobNotExist(); + this.doNavigateBack(); + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + title: `Loading Data job "${ this.jobName }" failed` + } + ); + } + }) + ); + } + + private _loadJobExecutions(): void { + this.subscriptions.push( + this.dataJobsApiService + .getJobExecutions(this.teamName, this.jobName, true, null, { property: 'startTime', direction: ASC }) + .subscribe({ + next: (value) => { + if (value?.content) { + this.dataJobsService.notifyForJobExecutions([...value.content]); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const runningExecution = value.content.find(DataJobUtil.isJobRunningPredicate); + if (runningExecution) { + this.dataJobsService.notifyForRunningJobExecutionId(runningExecution.id); + } + } + + this.areJobExecutionsLoaded = true; + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error) + ); + } + }) + ); + } + + private _replaceRunningExecutionAndNotify(executionDetails: DataJobExecutionDetails): void { + if (CollectionsUtil.isNil(executionDetails)) { + return; + } + + const convertedExecution = DataJobUtil.convertFromExecutionDetailsToExecutionState(executionDetails); + const foundIndex = this.jobExecutions.findIndex((ex) => ex.id === convertedExecution.id); + + if (foundIndex !== -1) { + this.jobExecutions.splice( + foundIndex, + 1, + convertedExecution + ); + } else { + this.jobExecutions.push(convertedExecution); + } + + this.dataJobsService.notifyForJobExecutions(this.jobExecutions); + } + + private _submitOperationStarted(type: TypeButtonState): void { + switch (type) { + case TypeButtonState.DELETE: + this.deleteButtonsState = ClrLoadingState.LOADING; + break; + case TypeButtonState.DOWNLOAD: + this.downloadButtonsState = ClrLoadingState.LOADING; + break; + case TypeButtonState.EXECUTE: + this.executeButtonsState = ClrLoadingState.LOADING; + break; + case TypeButtonState.STOP: + this.stopButtonsState = ClrLoadingState.LOADING; + break; + } + + this.loadingInProgress = true; + } + + private _submitOperationEnded(): void { + this.deleteButtonsState = ClrLoadingState.DEFAULT; + this.downloadButtonsState = ClrLoadingState.DEFAULT; + this.executeButtonsState = ClrLoadingState.DEFAULT; + this.stopButtonsState = ClrLoadingState.DEFAULT; + + this.loadingInProgress = false; + } + + private _extractJobDeployment(): DataJobDeployment { + if (!this.jobDeployments) { + return null; + } + + return this.jobDeployments[this.jobDeployments.length - 1]; + } + + private _showMessageJobNotExist(): void { + if (!this._nonExistingJobMsgShowed) { + this._nonExistingJobMsgShowed = true; + + this.toastService.show({ + type: VmwToastType.FAILURE, + title: `Job "${ this.jobName }" doesn't exist`, + description: `Data Job "${ this.jobName }" for Team "${ this.teamName }" doesn't exist, will load Data Jobs list` + }); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/index.ts new file mode 100644 index 0000000000..8ad30a9025 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-page.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.html new file mode 100644 index 0000000000..fa643101c9 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.html @@ -0,0 +1,360 @@ + + + + +
+
+
+
+
+ + + +
Status
+ +
+ +
+ +
+ + + + + + + + + + + +
+
+ + + +
Description
+ +
+ {{ + description.value }}
+ +
+ + {{ description.value | words: + descriptionWordsBeforeTruncate }} + + +
+ +
+ + + + +
+
+ + +
Owner team +
+
+ + {{team.value}} + +
+
+ + +
Schedule (in UTC)
+
+ {{ jobDetails?.config?.schedule?.schedule_cron | formatSchedule:'Not scheduled' }} + + + + Cron expression
+ {{jobDetails?.config?.schedule?.schedule_cron}} +
+
+ Next 5 executions +
    +
  1. + {{ + jobDetails?.config?.schedule?.schedule_cron | parseNextRun:times | date: + 'MMM d, y, hh:mm a':'UTC' + }} UTC +
  2. +
+
{{cronError}}
+
+
+
+ +
+ + + + +
+
+ + +
Source location
+
+
+
+ The data job is not deployed +
+ +
+
+
+
+
+
+
+ + +
Notifications + + + Notifications are used to inform the users about a specific activity. + To configure notifications, edit data job's config.ini file and redeploy the data + job. + + +
+ +
+
+ On Job Deployed +
+
+ +
+
+ {{contacts}} +
+
+
+
+ + + +
    +
  • + {{contacts}} +
  • +
+
+
+
+ + Not configured + +
+
+
+
+ +
+
+ +
+
+ {{contacts}} +
+
+
+
+ + + +
    +
  • + {{contacts}} +
  • +
+
+
+
+ + Not configured + +
+
+
+
+ +
+
+ +
+
+ {{contacts}} +
+
+
+
+ + + +
    +
  • + {{contacts}} +
  • +
+
+
+
+ + Not configured + +
+
+
+
+ +
+
+ +
+
+ {{contacts}} +
+
+
+
+ + + +
    +
  • + {{contacts}} +
  • +
+
+
+
+ + Not configured + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+ Last 5 Executions + +
+
+
+ + We couldn't find any executions, but you can always schedule one! + +
+ +
+
+
Loading executions...
+
+
+
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.scss new file mode 100644 index 0000000000..770161f719 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.scss @@ -0,0 +1,146 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@mixin fill-parent-content() { + display: flex; + flex-direction: column; + flex: 1 1 auto; +} + +hr { + border: 0; + height: 1px; + background: #8f9ba3; +} + +.label-link { + cursor: pointer; +} + +.label-link-suppress-decoration { + &:hover { + text-decoration: none; + } +} + +::ng-deep +.edit-column-modal { + .modal-header { + padding: 0 0 0 0 !important; + } +} + +::ng-deep +.clr-timeline-horizontal { + padding-top: 0 !important; +} + + +.status-icon-enabled { + color: hsl(93, 67%, 38%); +} + +.clr-col-6 { + border-right: 1px solid #999999; +} + +.placeholder-error-label { + color: gray; +} + +.grid-column-details { + display: grid; + height: 100%; + grid-template: "a a a b b b" auto "c c c c c c" auto "d d d d d d" auto "f f f f f f" auto/ 1fr 1fr 1fr 1fr 1fr 1fr; + + .grid-area-a { + grid-area: a; + } + + .grid-area-b { + grid-area: b; + } + + .grid-area-c { + grid-area: c; + } + + .grid-area-d { + grid-area: d; + } + + .grid-area-e { + grid-area: e; + } + + .grid-area-f { + grid-area: f; + } +} + +.clr-timeline-step clr-icon[shape=success-standard] { + color: var(--clr-timeline-success-step-color, #5eb715); +} + +.clr-timeline-step clr-icon[shape=error-standard] { + color: var(--clr-timeline-error-step-color, #c21d00); +} + +.clr-timeline-step clr-icon { + height: 1.8rem; + width: 1.8rem; + min-height: 1.8rem; + min-width: 1.8rem; +} + +.btn-show-more { + padding: 0 !important; +} + +:host { + @include fill-parent-content(); +} + +.data-pipelines-job__details-page { + @include fill-parent-content(); + + .data-pipelines-job__details-body { + @include fill-parent-content(); + + margin-bottom: 1rem; + + .data-pipelines-job__section--border-none { + border: none; + } + + .data-pipelines-job__contacts-container { + display: flex; + align-items: center; + + .data-pipelines-job__contacts-list-container { + display: inline-flex; + align-items: center; + + > div { + display: inline-flex; + margin-right: 0.3rem; + + &:last-child { + margin-right: 0; + } + } + } + + .data-pipelines-job__contacts-list-signpost-container { + display: inline-flex; + align-items: center; + + ul { + list-style-type: none; + } + } + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.spec.ts new file mode 100644 index 0000000000..94136a24b8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.spec.ts @@ -0,0 +1,323 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BehaviorSubject, of, Subject } from 'rxjs'; + +import { FORM_STATE, VMWFormState } from '@vdk/shared'; + +import { + ComponentModel, + ComponentService, + ComponentStateImpl, + ErrorHandlerService, + NavigationService, + RouterService, + RouterState, + RouteState, + ToastService +} from '@vdk/shared'; + +import { DataJobsApiService, DataJobsService } from '../../../../services'; + +import { + DATA_PIPELINES_CONFIGS, + DataJob, + DataJobDeploymentDetails, + DataJobDeploymentStatus, + DataJobDetails, + DataJobExecution, + DataJobExecutionsPage, + DataJobExecutionStatus, + DataJobExecutionType +} from '../../../../model'; +import { ExtractContactsPipe, ExtractJobStatusPipe, FormatSchedulePipe } from '../../../../shared/pipes'; + +import { DataJobDetailsPageComponent } from './data-job-details-page.component'; + +const TEST_JOB_EXECUTION = { + id: 'id002', + jobName: 'job002', + status: DataJobExecutionStatus.SUBMITTED, + startTime: new Date().toISOString(), + startedBy: 'aUserov', + endTime: new Date().toISOString(), + type: DataJobExecutionType.MANUAL, + opId: 'op002', + message: 'message001', + deployment: { + id: 'id002', + enabled: true, + jobVersion: '002', + mode: 'test_mode', + vdkVersion: '002', + resources: { + memoryLimit: 1000, + memoryRequest: 1000, + cpuLimit: 0.5, + cpuRequest: 0.5 + }, + executions: [], + deployedDate: '2020-11-11T10:10:10Z', + deployedBy: 'pmitev', + status: DataJobDeploymentStatus.SUCCESS + } +} as DataJobExecution; + +const TEST_JOB_DEPLOYMENT = { + id: 'id001', + enabled: true, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + job_version: '001', + mode: 'test_mode', + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + vdk_version: '001' +} as DataJobDeploymentDetails; + +const TEST_JOB_DETAILS = { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + job_name: 'job001', + team: 'taurus', + description: 'description' +}; + +describe('DataJobsDetailsModalComponent', () => { + let componentServiceStub: jasmine.SpyObj; + let navigationServiceStub: jasmine.SpyObj; + let activatedRouteStub: ActivatedRoute; + let routerServiceStub: jasmine.SpyObj; + let toastServiceStub: jasmine.SpyObj; + let dataJobsApiServiceStub: jasmine.SpyObj; + let dataJobsServiceStub: jasmine.SpyObj; + let errorHandlerServiceStub: jasmine.SpyObj; + + let componentModelStub: ComponentModel; + let component: DataJobDetailsPageComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + componentServiceStub = jasmine.createSpyObj('componentService', ['init', 'getModel', 'idle', 'update']); + navigationServiceStub = jasmine.createSpyObj('navigationService', ['navigateTo', 'navigateBack']); + activatedRouteStub = { snapshot: null } as any; + routerServiceStub = jasmine.createSpyObj('routerService', ['getState']); + toastServiceStub = jasmine.createSpyObj('toastService', ['show']); + dataJobsApiServiceStub = jasmine.createSpyObj('dataJobsApiService', [ + 'getJobDetails', + 'getJobExecutions', + 'getJobDeployments', + 'downloadFile', + 'updateDataJobStatus', + 'updateDataJob', + 'executeDataJob', + 'removeJob', + 'getJob' + ]); + dataJobsServiceStub = jasmine.createSpyObj('dataJobsService', [ + 'loadJobs', + 'loadJob', + 'notifyForRunningJobExecutionId', + 'notifyForJobExecutions', + 'notifyForTeamImplicitly', + 'getNotifiedForRunningJobExecutionId', + 'getNotifiedForJobExecutions', + 'getNotifiedForTeamImplicitly' + ]); + errorHandlerServiceStub = jasmine.createSpyObj('errorHandlerService', [ + 'processError', + 'handleError' + ]); + + dataJobsApiServiceStub.getJobDetails.and.returnValue(new BehaviorSubject(TEST_JOB_DETAILS).asObservable()); + dataJobsApiServiceStub.getJobExecutions.and + .returnValue(new BehaviorSubject({ + content: [TEST_JOB_EXECUTION], + totalItems: 1, + totalPages: 1 + }).asObservable()); + dataJobsApiServiceStub.getJobDeployments.and + .returnValue(new BehaviorSubject([TEST_JOB_DEPLOYMENT]).asObservable()); + dataJobsApiServiceStub.downloadFile.and.returnValue(new BehaviorSubject({} as never).asObservable()); + dataJobsApiServiceStub.updateDataJobStatus.and.returnValue(new BehaviorSubject<{ enabled: boolean }>({ enabled: true }).asObservable()); + dataJobsApiServiceStub.updateDataJob.and.returnValue(new BehaviorSubject({}).asObservable()); + dataJobsApiServiceStub.executeDataJob.and.returnValue(new BehaviorSubject(undefined).asObservable()); + dataJobsApiServiceStub.removeJob.and.returnValue(new BehaviorSubject(TEST_JOB_DETAILS).asObservable()); + dataJobsApiServiceStub.getJob.and.returnValue(of({ data: { content: [TEST_JOB_DETAILS] } } as DataJob)); + + dataJobsServiceStub.getNotifiedForJobExecutions.and.returnValue(new Subject()); + dataJobsServiceStub.getNotifiedForTeamImplicitly.and.returnValue(new BehaviorSubject(TEST_JOB_DETAILS.team)); + + componentModelStub = ComponentModel.of( + ComponentStateImpl.of({}), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + routerServiceStub.getState.and.returnValue(new Subject()); + componentServiceStub.init.and.returnValue(of(componentModelStub)); + componentServiceStub.getModel.and.returnValue(of(componentModelStub)); + + navigationServiceStub.navigateBack.and.returnValue(Promise.resolve(true)); + + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [ + DataJobDetailsPageComponent, + FormatSchedulePipe, + ExtractJobStatusPipe, + ExtractContactsPipe], + imports: [RouterTestingModule], + providers: [ + FormBuilder, + { provide: RouterService, useValue: routerServiceStub }, + { provide: ComponentService, useValue: componentServiceStub }, + { provide: NavigationService, useValue: navigationServiceStub }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: DataJobsApiService, useValue: dataJobsApiServiceStub }, + { provide: ToastService, useValue: toastServiceStub }, + { provide: DataJobsService, useValue: dataJobsServiceStub }, + { provide: ErrorHandlerService, useValue: errorHandlerServiceStub }, + { + provide: DATA_PIPELINES_CONFIGS, + useFactory: () => ({ + defaultOwnerTeamName: 'all', + manageConfig: { + allowKeyTabDownloads: true, + allowExecuteNow: true + } + }) + } + ] + }); + + fixture = TestBed.createComponent(DataJobDetailsPageComponent); + component = fixture.componentInstance; + component.jobDetails = TEST_JOB_DETAILS; + component.jobExecutions = [TEST_JOB_EXECUTION]; + component.model = componentModelStub; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + it(`readOnly has default value`, () => { + expect(component.isJobEditable).toBeFalse(); + }); + + it(`loadingExecutions has default value`, () => { + expect(component.loadingExecutions).toBeTrue(); + }); + + it(`canEditSection has default value`, () => { + expect(component.canEditSection).toBeTrue(); + }); + + it(`readOnly sets readFormState as formState`, () => { + component.isJobEditable = false; + component.ngOnInit(); + + expect(component.formState).toEqual(component.readFormState); + }); + + describe('isDescriptionSubmitEnabled', () => { + it('returns false', () => { + expect(component.isDescriptionSubmitEnabled()).toBeFalse(); + }); + }); + + describe('isStatusSubmitEnabled', () => { + it('returns false', () => { + expect(component.isStatusSubmitEnabled()).toBeFalse(); + }); + }); + + describe('_resetJobDetails', () => { + it('do not reset valid job', () => { + // @ts-ignore + component._resetJobDetails(); + expect(component.jobDetails).toEqual(TEST_JOB_DETAILS); + }); + + it('do reset invalid job', () => { + component.jobDetails = null; + // @ts-ignore + component._resetJobDetails(); + expect(component.jobDetails).toBeDefined(); + }); + }); + + describe('sectionStateChange', () => { + it('makes expected calls for FORM_STATE.SUBMIT', () => { + const vMWFormStateStub = {} as VMWFormState; + vMWFormStateStub.state = FORM_STATE.SUBMIT; + + spyOn(component, 'submitForm').and.callThrough(); + component.sectionStateChange(vMWFormStateStub); + expect(component.submitForm).toHaveBeenCalled(); + }); + }); + + describe('sectionStateChange', () => { + it('makes expected calls for FORM_STATE.CAN_EDIT', () => { + const vMWFormStateStub = {} as VMWFormState; + vMWFormStateStub.state = FORM_STATE.CAN_EDIT; + component.sectionStateChange(vMWFormStateStub); + expect(component.canEditSection).toBeTrue(); + }); + }); + + describe('sectionStateChange', () => { + it('makes expected calls for FORM_STATE.EDIT', () => { + const vMWFormStateStub = {} as VMWFormState; + vMWFormStateStub.state = FORM_STATE.EDIT; + + component.sectionStateChange(vMWFormStateStub); + expect(component.canEditSection).toBeFalse(); + }); + }); + + describe('doSubmit', () => { + it('makes expected calls for emittingSection description', () => { + const vMWFormStateStub = {} as VMWFormState; + vMWFormStateStub.emittingSection = 'description'; + + spyOn(component, 'isDescriptionSubmitEnabled').and.callThrough(); + component.submitForm(vMWFormStateStub); + expect(component.isDescriptionSubmitEnabled).toHaveBeenCalled(); + }); + + it('makes expected calls for emittingSection status', () => { + const vMWFormStateStub = {} as VMWFormState; + vMWFormStateStub.emittingSection = 'status'; + + spyOn(component, 'isStatusSubmitEnabled').and.callThrough(); + component.submitForm(vMWFormStateStub); + expect(component.isStatusSubmitEnabled).toHaveBeenCalled(); + }); + }); + + describe('ngOnInit', () => { + it('makes expected calls', () => { + routerServiceStub.getState.and.returnValue(of(RouteState.empty())); + component.ngOnInit(); + + expect(dataJobsServiceStub.loadJob).toHaveBeenCalled(); + }); + }); + + describe('editOperationEnded', () => { + it('sets expected states', () => { + component.editOperationEnded(); + expect(component.formState.state).toEqual(FORM_STATE.CAN_EDIT); + expect(component.canEditSection).toBeTrue(); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.ts new file mode 100644 index 0000000000..f842c64cae --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/data-job-details-page.component.ts @@ -0,0 +1,492 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { take } from 'rxjs/operators'; + +import { FORM_STATE, VMWFormState, VmwToastType } from '@vdk/shared'; + +import { + ASC, + CollectionsUtil, + ComponentModel, + ComponentService, + ErrorHandlerConfig, + ErrorHandlerService, + NavigationService, + OnTaurusModelChange, + OnTaurusModelError, + OnTaurusModelInit, + OnTaurusModelLoad, + RouterService, + RouteState, + TaurusBaseComponent, + ToastService +} from '@vdk/shared'; + +import { ConfirmationModalOptions, DeleteModalOptions, ModalOptions } from '../../../../shared/model'; +import { CronUtil, DataJobUtil, ErrorUtil, StringUtil } from '../../../../shared/utils'; +import { ExtractJobStatusPipe, ParseEpochPipe } from '../../../../shared/pipes'; + +import { + DATA_PIPELINES_CONFIGS, + DataJob, + DataJobDeployment, + DataJobDetails, + DataJobExecutionOrder, + DataJobExecutions, + DataJobStatus, + DataPipelinesConfig, + JOB_DEPLOYMENT_ID_REQ_PARAM, + JOB_DETAILS_DATA_KEY, + JOB_DETAILS_REQ_PARAM, + JOB_EXECUTIONS_DATA_KEY, + JOB_NAME_REQ_PARAM, + JOB_STATE_DATA_KEY, + JOB_STATE_REQ_PARAM, + JOB_STATUS_REQ_PARAM, + ORDER_REQ_PARAM, + TEAM_NAME_REQ_PARAM +} from '../../../../model'; +import { DataJobsApiService, DataJobsService } from '../../../../services'; + +import { + TASK_LOAD_JOB_DETAILS, + TASK_LOAD_JOB_EXECUTIONS, + TASK_LOAD_JOB_STATE, + TASK_UPDATE_JOB_DESCRIPTION, + TASK_UPDATE_JOB_STATUS +} from '../../../../state/tasks'; + +@Component({ + selector: 'lib-data-job-details-page', + templateUrl: './data-job-details-page.component.html', + styleUrls: ['./data-job-details-page.component.scss'] +}) +export class DataJobDetailsPageComponent + extends TaurusBaseComponent + implements OnInit, OnTaurusModelInit, OnTaurusModelLoad, OnTaurusModelChange, OnTaurusModelError { + + readonly uuid = 'DataJobDetailsPageComponent'; + + dataJobStatusEnum = DataJobStatus; + + jobName: string; + teamName: string; + jobState: DataJob; + jobDetails: DataJobDetails; + jobExecutions: DataJobExecutions = []; + + isJobEditable = false; + + shouldShowTeamsSection = false; + + cronError: string = null; + + next: Date; + + loadingExecutions = true; + loadingInProgress = false; + allowExecutionsByDeployment = false; + + tmForm: FormGroup; + formState: VMWFormState; + readFormState: VMWFormState; + editableFormState: VMWFormState; + + canEditSection = true; + + collectorOptions: ModalOptions; + confirmationOptions: ModalOptions; + deleteOptions: ModalOptions; + executeNowOptions: ModalOptions; + + showFullDescription = false; + + descriptionWordsBeforeTruncate = 12; + + get name() { + return this.tmForm.get('name'); + } + + get team() { + return this.tmForm.get('team'); + } + + get status() { + return this.tmForm.get('status'); + } + + get description() { + return this.tmForm.get('description'); + } + + constructor( // NOSONAR + componentService: ComponentService, + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + private readonly router: Router, + private readonly routerService: RouterService, + private readonly dataJobsService: DataJobsService, + private readonly dataJobsApiService: DataJobsApiService, + private readonly formBuilder: FormBuilder, + private readonly toastService: ToastService, + private readonly errorHandlerService: ErrorHandlerService, + @Inject(DATA_PIPELINES_CONFIGS) public readonly dataPipelinesModuleConfig: DataPipelinesConfig + ) { + super(componentService, navigationService, activatedRoute); + + this.formState = new VMWFormState(FORM_STATE.VIEW); + this.readFormState = new VMWFormState(FORM_STATE.VIEW); + this.editableFormState = new VMWFormState(FORM_STATE.CAN_EDIT); + + this.confirmationOptions = new ConfirmationModalOptions(); + this.deleteOptions = new DeleteModalOptions(); + this.executeNowOptions = new ConfirmationModalOptions(); + + this._initForm(); + } + + isDescriptionSubmitEnabled(): boolean { + return this._isFormSubmitEnabled() && this.description.value !== this.jobDetails.description; + } + + isStatusSubmitEnabled(): boolean { + return this._isFormSubmitEnabled() && this.status.value !== ExtractJobStatusPipe.transform(this.jobState?.deployments); + } + + isJobRunning(): boolean { + return DataJobUtil.isJobRunning(this.jobExecutions); + } + + showNoNotificationsLabel(notifications: string[]): boolean { + return !notifications || notifications.length < 1; + } + + sectionStateChange(sectionState: VMWFormState) { + if (sectionState.state === FORM_STATE.CAN_EDIT) { + switch (sectionState.emittingSection) { + case 'status': + this.status.setValue(ExtractJobStatusPipe.transform(this.jobState?.deployments)); + break; + case 'description': + this.description.setValue(this.jobDetails.description); + break; + default: + break; + } + this.canEditSection = true; + } else if (sectionState.state === FORM_STATE.SUBMIT) { + this.submitForm(sectionState); + } else if (sectionState.state === FORM_STATE.EDIT) { + this.canEditSection = false; + } + } + + submitForm(event: VMWFormState) { + if (event.emittingSection === 'description' && this.isDescriptionSubmitEnabled()) { + this._doSubmitDescriptionUpdate(); + } + + if (event.emittingSection === 'status' && this.isStatusSubmitEnabled()) { + this._doSubmitStatusUpdate(); + } + } + + editOperationEnded() { + this.formState = new VMWFormState(FORM_STATE.CAN_EDIT); + this.canEditSection = true; + } + + loadJobExecutions() { + this.dataJobsService.loadJobExecutions(this.model); + } + + redirectToHealthStatus() { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router + .navigateByUrl( + StringUtil.stringFormat( + this.dataPipelinesModuleConfig.healthStatusUrl, + this.jobDetails.job_name + ) + ) + .then(); + } + + /** + * @inheritDoc + */ + onModelInit(): void { + this.routerService + .getState() + .pipe( + take(1) + ) + .subscribe((routeState) => this._initialize(routeState)); + } + + /** + * @inheritDoc + */ + onModelLoad(model: ComponentModel, task: string): void { + if (task === TASK_LOAD_JOB_EXECUTIONS) { + this.loadingExecutions = false; + } + } + + /** + * @inheritDoc + */ + onModelChange(model: ComponentModel, task: string): void { + if (task === TASK_LOAD_JOB_STATE) { + this.jobState = model.getComponentState().data.get(JOB_STATE_DATA_KEY); + this._initializeNextRunDate(); + this.allowExecutionsByDeployment = ExtractJobStatusPipe + .transform(this.jobState?.deployments) !== DataJobStatus.NOT_DEPLOYED; + this.cronError = CronUtil.getNextExecutionErrors(this.jobState?.config?.schedule?.scheduleCron); + + return; + } + + if (task === TASK_LOAD_JOB_DETAILS) { + this.jobDetails = model.getComponentState().data.get(JOB_DETAILS_DATA_KEY); + this._updateForm(); + + return; + } + + if (task === TASK_LOAD_JOB_EXECUTIONS) { + const executions: DataJobExecutions = model.getComponentState() + .data + .get(JOB_EXECUTIONS_DATA_KEY); + + if (executions) { + this.dataJobsService.notifyForJobExecutions([...executions]); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const runningExecution = executions.find(DataJobUtil.isJobRunningPredicate); + if (runningExecution) { + this.dataJobsService.notifyForRunningJobExecutionId(runningExecution.id); + } + } + + return; + } + + if (task === TASK_UPDATE_JOB_DESCRIPTION) { + this.toastService.show({ + type: VmwToastType.INFO, + title: `Description update completed`, + description: `Data job "${ this.jobName }" description successfully updated` + }); + + this.jobDetails = model.getComponentState().data.get(JOB_DETAILS_DATA_KEY); + + this.editOperationEnded(); + + return; + } + + if (task === TASK_UPDATE_JOB_STATUS) { + this.toastService.show({ + type: VmwToastType.INFO, + title: `Status update completed`, + description: `Data job "${ this.jobName }" successfully ` + + `${ !this._extractJobDeployment()?.enabled ? 'enabled' : 'disabled' }` + }); + + this.jobState = model.getComponentState().data.get(JOB_STATE_DATA_KEY); + + this.editOperationEnded(); + } + } + + /** + * @inheritDoc + */ + onModelError(model: ComponentModel, task: string): void { + const error = ErrorUtil.extractError( + model.getComponentState().error + ); + + let errorHandlerConfig: ErrorHandlerConfig; + + switch (task) { + case TASK_LOAD_JOB_DETAILS: + this._resetJobDetails(); + break; + case TASK_LOAD_JOB_EXECUTIONS: + // No-op. + break; + case TASK_LOAD_JOB_STATE: + // No-op. + break; + case TASK_UPDATE_JOB_DESCRIPTION: + errorHandlerConfig = { + title: 'Description update failed' + }; + this.editOperationEnded(); + break; + case TASK_UPDATE_JOB_STATUS: + errorHandlerConfig = { + title: 'Status update failed' + }; + this.editOperationEnded(); + break; + default: + // No-op. + } + + this.errorHandlerService.processError(error, errorHandlerConfig); + } + + /** + * @inheritDoc + */ + override ngOnInit() { + super.ngOnInit(); + + this._initializeNextRunDate(); + + this.shouldShowTeamsSection = this.dataPipelinesModuleConfig + ?.exploreConfig + ?.showTeamsColumn; + } + + private _initialize(state: RouteState): void { + const teamParamKey = state.getData('teamParamKey'); + this.teamName = state.getParam(teamParamKey); + + if (CollectionsUtil.isNil(teamParamKey) || CollectionsUtil.isNil(this.teamName)) { + this._subscribeForImplicitTeam(); + } + + const jobParamKey = state.getData('jobParamKey'); + this.jobName = state.getParam(jobParamKey); + + this.isJobEditable = !!state.getData('editable'); + + if (this.isJobEditable) { + this.formState = this.editableFormState; + } + + this._subscribeForExecutions(); + + this.dataJobsService.loadJob( + this.model + .withRequestParam(TEAM_NAME_REQ_PARAM, this.teamName) + .withRequestParam(JOB_NAME_REQ_PARAM, this.jobName) + .withRequestParam(ORDER_REQ_PARAM, { property: 'startTime', direction: ASC } as DataJobExecutionOrder) + ); + } + + private _subscribeForImplicitTeam(): void { + this.dataJobsService + .getNotifiedForTeamImplicitly() + .pipe( + take(1) + ) + .subscribe((teamName) => this.teamName = teamName); + } + + private _extractJobDeployment(): DataJobDeployment { + if (!this.jobState?.deployments) { + return null; + } + return this.jobState?.deployments[this.jobState?.deployments.length - 1]; + } + + private _isFormSubmitEnabled(): boolean { + return !this.tmForm?.pristine && this.tmForm?.valid; + } + + private _initForm(): void { + this.tmForm = this.formBuilder.group({ + name: '', + team: '', + status: '', + description: '' + }); + } + + private _updateForm(): void { + this.tmForm.setValue({ + name: this.jobDetails.job_name, + team: this.jobDetails.team, + status: ExtractJobStatusPipe.transform(this.jobState?.deployments), + description: this.jobDetails.description + }); + } + + private _doSubmitDescriptionUpdate(): void { + const jobDetailsUpdated: DataJobDetails = { + ...this.jobDetails, + description: this.description.value as string + }; + + this.dataJobsService.updateJob( + this.model + .withRequestParam(TEAM_NAME_REQ_PARAM, jobDetailsUpdated.team) + .withRequestParam(JOB_NAME_REQ_PARAM, jobDetailsUpdated.job_name) + .withRequestParam(JOB_DETAILS_REQ_PARAM, jobDetailsUpdated), + TASK_UPDATE_JOB_DESCRIPTION + ); + } + + private _doSubmitStatusUpdate(): void { + const jobDeployment = this._extractJobDeployment(); + + if (!jobDeployment) { + console.log('Status update will not be performed for job with no deployments.'); + + return; + } + + const jobState: DataJob = { + ...this.jobState, + deployments: [ + { + ...this.jobState.deployments[0], + enabled: this.status.value === DataJobStatus.ENABLED + }, + ...this.jobState.deployments.slice(1) + ] + }; + + this.dataJobsService.updateJob( + this.model + .withRequestParam(TEAM_NAME_REQ_PARAM, this.jobDetails.team) + .withRequestParam(JOB_NAME_REQ_PARAM, this.jobDetails.job_name) + .withRequestParam(JOB_DEPLOYMENT_ID_REQ_PARAM, jobDeployment.id) + .withRequestParam(JOB_STATUS_REQ_PARAM, this.status.value === DataJobStatus.ENABLED) + .withRequestParam(JOB_STATE_REQ_PARAM, jobState), + TASK_UPDATE_JOB_STATUS + ); + } + + private _initializeNextRunDate(): void { + this.next = ParseEpochPipe.transform(this.jobState?.config?.schedule?.nextRunEpochSeconds); + } + + private _subscribeForExecutions(): void { + this.subscriptions.push( + this.dataJobsService + .getNotifiedForJobExecutions() + .subscribe((executions) => { + this.jobExecutions = executions; + }) + ); + } + + private _resetJobDetails(): void { + if (!this.jobDetails) { + this.jobDetails = {}; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/index.ts new file mode 100644 index 0000000000..f9d46c3283 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-details-page.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/public-api.ts new file mode 100644 index 0000000000..484b60ff3c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/details/public-api.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './index'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.html new file mode 100644 index 0000000000..9b1cfad3cd --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.html @@ -0,0 +1,82 @@ + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.scss new file mode 100644 index 0000000000..ed36651fc8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.scss @@ -0,0 +1,13 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +div.clr-row div { + display: flex; + flex-direction: column; + + p, h6 { + margin-top: 12px; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.ts new file mode 100644 index 0000000000..f141aa9c79 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/data-job-deployment-details-modal.component.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { DataJobDeployment } from '../../../../../model'; + +@Component({ + selector: 'lib-data-job-deployment-details-modal', + templateUrl: './data-job-deployment-details-modal.component.html', + styleUrls: ['./data-job-deployment-details-modal.component.scss'] +}) +export class DataJobDeploymentDetailsModalComponent { + + @Output() openModalChange = new EventEmitter(); + @Input() dataJobDeployment: DataJobDeployment; + + private openModalValue: boolean; + + @Input() + set openModal(value) { + this.openModalValue = value; + this.openModalChange.emit(this.openModalValue); + } + + get openModal() { + return this.openModalValue; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/index.ts new file mode 100644 index 0000000000..f655a2fa82 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-deployment-details-modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-deployment-details-modal.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/data-job-execution-status-filter.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/data-job-execution-status-filter.component.html new file mode 100644 index 0000000000..8fd5617e4c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/data-job-execution-status-filter.component.html @@ -0,0 +1,14 @@ + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/data-job-execution-status-filter.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/data-job-execution-status-filter.component.ts new file mode 100644 index 0000000000..76e7ac6b7c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/data-job-execution-status-filter.component.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component } from '@angular/core'; + +import { Subject } from 'rxjs'; + +import { ClrDatagridFilterInterface } from '@clr/angular'; + +import { DataJobExecutionStatus } from '../../../../../model'; + +import { GridDataJobExecution } from '../model'; + +@Component({ + selector: 'lib-data-job-execution-status-filter', + templateUrl: './data-job-execution-status-filter.component.html' +}) +export class DataJobExecutionStatusFilterComponent implements ClrDatagridFilterInterface { + + allStatuses = [ + DataJobExecutionStatus.SUCCEEDED, + DataJobExecutionStatus.PLATFORM_ERROR, + DataJobExecutionStatus.USER_ERROR, + DataJobExecutionStatus.RUNNING, + DataJobExecutionStatus.SUBMITTED, + DataJobExecutionStatus.SKIPPED, + DataJobExecutionStatus.CANCELLED + ]; + + selectedStatuses: string[] = []; + + changes: Subject = new Subject(); + + isActive(): boolean { + return this.selectedStatuses.length > 0; + } + + accepts(item: GridDataJobExecution): boolean { + return this.selectedStatuses.indexOf(item.status) > -1; + } + + toggleCheckbox($event: Event) { + const checkbox = $event.target as HTMLInputElement; + + if (checkbox.checked) { + this.selectedStatuses.push(checkbox.value); + } else { + const statusToRemoveIndex = this.selectedStatuses.indexOf(checkbox.value); + if (statusToRemoveIndex > -1) { + this.selectedStatuses.splice(statusToRemoveIndex, 1); + } + } + + this.changes.next(true); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/index.ts new file mode 100644 index 0000000000..f94f6a7680 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status-filter/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-execution-status-filter.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.html new file mode 100644 index 0000000000..877ceab7c1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.html @@ -0,0 +1,24 @@ + + + + + +
+ + {{ jobMessage }} + + +
+ {{executionStatusProperties.text}} + +
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.scss new file mode 100644 index 0000000000..0bb2097f19 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.scss @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.job-execution-status__text { + margin-left: 0.25rem; +} + +:host { + display: block; + width: 100%; + height: 100%; +} + +.job-execution-signpost { + cursor: auto; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.ts new file mode 100644 index 0000000000..82060bb5e2 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/data-job-execution-status.component.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, HostListener, Input } from '@angular/core'; + +import { DataJobExecutionStatus } from '../../../../../model'; + +type StatusPropertiesMapping = { shape: string; status: string; direction: string; text: string }; + +@Component({ + selector: 'lib-data-job-execution-status', + templateUrl: './data-job-execution-status.component.html', + styleUrls: ['./data-job-execution-status.component.scss'] +}) +export class DataJobExecutionStatusComponent { + + @HostListener('mouseenter') onMouseEnter(): void { + this.clrOpen = true; + } + + @HostListener('mouseleave') onMouseLeave(): void { + this.clrOpen = false; + } + + @Input() jobStatus: DataJobExecutionStatus; + @Input() jobMessage = ''; + @Input() showErrorMessage = false; + clrOpen = false; + + statusPropertiesMapping: { [key: string]: StatusPropertiesMapping } = { + [DataJobExecutionStatus.SUBMITTED]: { shape: 'hourglass', status: '', direction: '', text: 'Submitted' }, + [DataJobExecutionStatus.RUNNING]: { shape: 'play', status: '', direction: '', text: 'Running' }, + [DataJobExecutionStatus.SUCCEEDED]: { shape: 'success-standard', status: 'is-success', direction: '', text: 'Success' }, + [DataJobExecutionStatus.CANCELLED]: { shape: 'ban', status: '', direction: '', text: 'Canceled' }, + [DataJobExecutionStatus.SKIPPED]: { shape: 'angle-double', status: '', direction: 'right', text: 'Skipped' }, + [DataJobExecutionStatus.USER_ERROR]: { shape: 'error-standard', status: 'is-danger', direction: '', text: 'User Error' }, + [DataJobExecutionStatus.PLATFORM_ERROR]: { shape: 'error-standard', status: 'is-warning', direction: '', text: 'Platform Error' }, + [DataJobExecutionStatus.FAILED]: { shape: 'error-standard', status: 'is-danger', direction: '', text: 'Error' } + }; + + get executionStatusProperties(): StatusPropertiesMapping { + return this.statusPropertiesMapping[this.jobStatus] ?? {} as StatusPropertiesMapping; + } + + isJobStatusSuitableForMessageTooltip(): boolean { + return this.jobStatus === DataJobExecutionStatus.PLATFORM_ERROR || this.jobStatus === DataJobExecutionStatus.USER_ERROR || this.jobStatus === DataJobExecutionStatus.SKIPPED; + } + + isJobMessageDifferentFromStatus(): boolean { + const message = this.jobMessage?.toLowerCase(); + return message !== 'user error' && message !== 'platform error' && message !== 'skipped' && message !== ''; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/index.ts new file mode 100644 index 0000000000..d18783e33f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-status/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-execution-status.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/data-job-execution-type-filter.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/data-job-execution-type-filter.component.html new file mode 100644 index 0000000000..a7ed4c9178 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/data-job-execution-type-filter.component.html @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/data-job-execution-type-filter.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/data-job-execution-type-filter.component.ts new file mode 100644 index 0000000000..228a1e00c7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/data-job-execution-type-filter.component.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component } from '@angular/core'; + +import { Subject } from 'rxjs'; + +import { ClrDatagridFilterInterface } from '@clr/angular'; + +import { DataJobExecutionType } from '../../../../../model'; + +import { GridDataJobExecution } from '../model/data-job-execution'; + +@Component({ + selector: 'lib-data-job-execution-type-filter', + templateUrl: './data-job-execution-type-filter.component.html' +}) +export class DataJobExecutionTypeFilterComponent implements ClrDatagridFilterInterface { + allTypes = [DataJobExecutionType.MANUAL, DataJobExecutionType.SCHEDULED]; + selectedTypes: string[] = []; + changes: Subject = new Subject(); + + isActive(): boolean { + return this.selectedTypes.length > 0; + } + + accepts(item: GridDataJobExecution): boolean { + return this.selectedTypes.indexOf(item.type) > -1; + } + + toggleCheckbox(event: Event) { + const checkbox = event.target as HTMLInputElement; + + if (checkbox.checked) { + this.selectedTypes.push(checkbox.value); + } else { + const statusToRemoveIndex = this.selectedTypes.indexOf(checkbox.value); + if (statusToRemoveIndex > -1) { + this.selectedTypes.splice(statusToRemoveIndex, 1); + } + } + + this.changes.next(true); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/index.ts new file mode 100644 index 0000000000..d267de4844 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type-filter/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-execution-type-filter.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/data-job-execution-type.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/data-job-execution-type.component.html new file mode 100644 index 0000000000..9ffd1d4844 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/data-job-execution-type.component.html @@ -0,0 +1,11 @@ + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/data-job-execution-type.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/data-job-execution-type.component.ts new file mode 100644 index 0000000000..c7a57ca678 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/data-job-execution-type.component.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input } from '@angular/core'; + +import { DataJobExecutionType } from '../../../../../model'; + +import { GridDataJobExecution } from '../model/data-job-execution'; + +@Component({ + selector: 'lib-data-job-execution-type', + templateUrl: './data-job-execution-type.component.html' +}) +export class DataJobExecutionTypeComponent { + + @Input() jobExecution: GridDataJobExecution; + + executionTypePropertiesMapping = { + [DataJobExecutionType.MANUAL]: { shape: 'cursor-hand-open', status: 'is-info' }, + [DataJobExecutionType.SCHEDULED]: { shape: 'clock', status: '' } + }; + + get executionTypeProperties() { + return this.executionTypePropertiesMapping[this.jobExecution.type]; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/index.ts new file mode 100644 index 0000000000..a9d1f5cdb7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-execution-type/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-execution-type.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/comparators/execution-duration-comparator.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/comparators/execution-duration-comparator.spec.ts new file mode 100644 index 0000000000..5b6eaf8759 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/comparators/execution-duration-comparator.spec.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataJobExecutionDurationComparator } from './execution-duration-comparator'; + +describe('DataJobExecutionDurationComparator', () => { + describe('Methods::', () => { + describe('|compare|', () => { + it('should verify will return -10', () => { + // Given + const aStartTime = new Date(); + const aEndTime = new Date(aStartTime.getTime() + 100); + const bStartTime = new Date(); + const bEndTime = new Date(bStartTime.getTime() + 110); + const instance = new DataJobExecutionDurationComparator(); + + // When + const res = instance.compare( + { id: 'aJob', startTime: aStartTime.toISOString(), endTime: aEndTime.toISOString(), duration: '100', jobVersion: '' }, + { id: 'bJob', startTime: bStartTime.toISOString(), endTime: bEndTime.toISOString(), duration: '110', jobVersion: '' } + ); + + // Then + expect(res).toEqual(-10); + }); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/comparators/execution-duration-comparator.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/comparators/execution-duration-comparator.ts new file mode 100644 index 0000000000..f32f79d8b1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/comparators/execution-duration-comparator.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ClrDatagridComparatorInterface } from '@clr/angular'; + +import { GridDataJobExecution } from '../../model/data-job-execution'; + +export class DataJobExecutionDurationComparator implements ClrDatagridComparatorInterface { + /** + * @inheritDoc + */ + compare(a: GridDataJobExecution, b: GridDataJobExecution) { + const aDuration = Date.parse(a.endTime) - Date.parse(a.startTime); + const bDuration = Date.parse(b.endTime) - Date.parse(b.startTime); + + return aDuration - bDuration; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.html new file mode 100644 index 0000000000..f857e9a2d7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.html @@ -0,0 +1,95 @@ + + + + + We couldn't find any executions! + + Status + + + + + Type + + + + + Duration + + Start (UTC) + + End (UTC) + + ID + + Version + + Logs + + + + + + + + + + {{ jobExecution.duration }} + {{ jobExecution.startTime | date:'MMM d, y, hh:mm:ss a':'UTC' }} + {{ jobExecution.endTime | date:'MMM d, y, hh:mm:ss a':'UTC' }} + {{ jobExecution.id }} + {{ jobExecution.jobVersion | slice:0:8 }} + + + + + + + + + + + + + + Executions per page + {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} of {{pagination.totalItems}} executions + + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.scss new file mode 100644 index 0000000000..69e935d830 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.scss @@ -0,0 +1,16 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.execution-type-cell { + text-align: center; +} + +.grid-column__min-width--s { + min-width: 3.5rem; +} + +.grid-column__max-width--s { + max-width: 4rem; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.ts new file mode 100644 index 0000000000..9ddacb03b1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/data-job-executions-grid.component.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input } from '@angular/core'; + +import { ClrDatagridSortOrder } from '@clr/angular'; + +import { DataJobDeployment } from '../../../../../model'; + +import { GridDataJobExecution } from '../model'; + +import { DataJobExecutionDurationComparator } from './comparators/execution-duration-comparator'; + +@Component({ + selector: 'lib-data-job-executions-grid', + templateUrl: './data-job-executions-grid.component.html', + styleUrls: ['./data-job-executions-grid.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DataJobExecutionsGridComponent { + + @Input() jobExecutions: GridDataJobExecution[]; + @Input() loading = false; + + // Sorting + descSort = ClrDatagridSortOrder.DESC; + durationComparator = new DataJobExecutionDurationComparator(); + // End of sorting + openDeploymentDetailsModal = false; + jobDeploymentModalData: DataJobDeployment; + + /** + * ** Constructor. + */ + constructor(private readonly changeDetectorRef: ChangeDetectorRef) { + } + + /** + * ** NgFor elements tracking function. + */ + trackByFn(index: number, execution: GridDataJobExecution): string { + return `${ index }|${ execution?.id }`; + } + + showDeploymentDetails(jobExecution: GridDataJobExecution) { + this.openDeploymentDetailsModal = true; + this.jobDeploymentModalData = jobExecution.deployment; + + this.changeDetectorRef.detectChanges(); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/index.ts new file mode 100644 index 0000000000..bdc2c7ff67 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-grid/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-executions-grid.component'; +export * from './comparators/execution-duration-comparator'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.html new file mode 100644 index 0000000000..40314bf2ac --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.html @@ -0,0 +1,59 @@ + + + + +
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+ +
+
+ + +

No executions found.

+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.scss new file mode 100644 index 0000000000..b689e45972 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.scss @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +:host { + position: relative; + display: block; + width: 100%; + height: 100%; + + .time-filter__container { + display: flex; + align-items: center; + flex: 1; + justify-content: flex-end; + height: 2.5rem; + } + + .execution-statuses-chart { + padding-right: 0 !important; + } + + .job-executions__spinner { + left: 50%; + position: absolute; + top: 50%; + z-index: 1000; + transform: translate(-50%, -50%); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.ts new file mode 100644 index 0000000000..dab4ace8a3 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/data-job-executions-page.component.ts @@ -0,0 +1,236 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { map, take } from 'rxjs/operators'; + +import { + ASC, + CollectionsUtil, + ComponentModel, + ComponentService, + ErrorHandlerService, + NavigationService, + OnTaurusModelChange, + OnTaurusModelError, + OnTaurusModelInit, + OnTaurusModelLoad, + RouterService, + RouteState, + TaurusBaseComponent +} from '@vdk/shared'; + +import { DataJobUtil, ErrorUtil } from '../../../../../shared/utils'; + +import { + DataJobExecutionFilter, + DataJobExecutionOrder, + DataJobExecutions, + DataPipelinesRouteData, + FILTER_REQ_PARAM, + JOB_EXECUTIONS_DATA_KEY, + JOB_NAME_REQ_PARAM, + ORDER_REQ_PARAM, + TEAM_NAME_REQ_PARAM +} from '../../../../../model'; +import { DataJobsService } from '../../../../../services'; +import { TASK_LOAD_JOB_EXECUTIONS } from '../../../../../state/tasks'; + +import { DataJobExecutionToGridDataJobExecution, GridDataJobExecution } from '../model/data-job-execution'; + +@Component({ + selector: 'lib-data-job-executions-page', + templateUrl: './data-job-executions-page.component.html', + styleUrls: ['./data-job-executions-page.component.scss'] +}) +export class DataJobExecutionsPageComponent + extends TaurusBaseComponent + implements OnTaurusModelInit, OnTaurusModelLoad, OnTaurusModelChange, OnTaurusModelError { + + readonly uuid = 'DataJobExecutionsPageComponent'; + + teamName: string; + jobName: string; + isJobEditable = false; + + jobExecutions: GridDataJobExecution[]; + minJobExecutionTime: Date; + loading = true; + initialLoading = true; + + dateTimeFilter: { fromTime: Date; toTime: Date } = { fromTime: null, toTime: null }; + + constructor( + componentService: ComponentService, + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + private readonly routerService: RouterService, + private readonly dataJobsService: DataJobsService, + private readonly errorHandlerService: ErrorHandlerService) { + + super(componentService, navigationService, activatedRoute); + } + + doNavigateBack(): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.navigateBack({ '$.team': this.teamName }) + .then(); + } + + onTimeFilterChange(dateTimeFilter: { fromTime: Date; toTime: Date }): void { + this.dateTimeFilter = dateTimeFilter; + + const modelFilter: DataJobExecutionFilter = this + .model + .getComponentState() + .requestParams + .get(FILTER_REQ_PARAM) ?? {}; + + if (CollectionsUtil.isNil(dateTimeFilter.fromTime) || CollectionsUtil.isNil(dateTimeFilter.toTime)) { + delete modelFilter.startTimeGte; + delete modelFilter.startTimeLte; + + this.model + .withRequestParam( + FILTER_REQ_PARAM, + { + ...modelFilter + } as DataJobExecutionFilter + ); + } else { + this.model + .withRequestParam( + FILTER_REQ_PARAM, + { + ...modelFilter, + startTimeGte: dateTimeFilter.fromTime, + startTimeLte: dateTimeFilter.toTime + } as DataJobExecutionFilter + ); + } + + this.fetchDataJobExecutions(); + } + + fetchDataJobExecutions(): void { + this.loading = true; + + this.dataJobsService.loadJobExecutions( + this.model + .withRequestParam(TEAM_NAME_REQ_PARAM, this.teamName) + .withRequestParam(JOB_NAME_REQ_PARAM, this.jobName) + .withRequestParam(ORDER_REQ_PARAM, { property: 'startTime', direction: ASC } as DataJobExecutionOrder) + ); + } + + /** + * @inheritDoc + */ + onModelInit(): void { + this.routerService + .getState() + .pipe( + take(1) + ) + .subscribe((routeState) => this._initialize(routeState)); + } + + /** + * @inheritDoc + */ + onModelLoad(): void { + this.loading = false; + this.initialLoading = false; + } + + /** + * @inheritDoc + */ + onModelChange(model: ComponentModel, task: string): void { + if (task === TASK_LOAD_JOB_EXECUTIONS) { + const executions: DataJobExecutions = model.getComponentState().data.get(JOB_EXECUTIONS_DATA_KEY); + if (executions) { + this.dataJobsService.notifyForJobExecutions([...executions]); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const runningExecution = executions.find(DataJobUtil.isJobRunningPredicate); + if (runningExecution) { + this.dataJobsService.notifyForRunningJobExecutionId(runningExecution.id); + } + } + } + } + + /** + * @inheritDoc + */ + onModelError(model: ComponentModel): void { + const error = ErrorUtil.extractError( + model.getComponentState().error + ); + + this.errorHandlerService.processError(error); + } + + private _initialize(state: RouteState): void { + const teamParamKey = state.getData('teamParamKey'); + this.teamName = state.getParam(teamParamKey); + + const jobParamKey = state.getData('jobParamKey'); + this.jobName = state.getParam(jobParamKey); + + this.isJobEditable = !!state.getData('editable'); + + this._subscribeForExecutions(); + + this.fetchDataJobExecutions(); + } + + private _subscribeForExecutions(): void { + this.subscriptions.push( + this.dataJobsService.getNotifiedForJobExecutions() + .pipe( + // eslint-disable-next-line @typescript-eslint/unbound-method + map(DataJobExecutionToGridDataJobExecution.convertToDataJobExecution) + ) + .subscribe({ + next: (values) => { + this.jobExecutions = values.filter((ex) => { + if (CollectionsUtil.isNil(this.dateTimeFilter.fromTime) || + CollectionsUtil.isNil(this.dateTimeFilter.toTime)) { + + return true; + } + + if (!CollectionsUtil.isString(ex.startTime)) { + return false; + } + + const startTime = new Date(ex.startTime); + + return startTime > this.dateTimeFilter.fromTime && startTime < this.dateTimeFilter.toTime; + }); + + if (this.jobExecutions.length > 0) { + const newMinJobExecutionsTime = new Date( + this.jobExecutions.reduce((prev, curr) => prev.startTime < curr.startTime ? prev : curr).startTime + ); + + if (CollectionsUtil.isNil(this.minJobExecutionTime) || + (newMinJobExecutionsTime.getTime() - this.minJobExecutionTime.getTime()) !== 0) { + + this.minJobExecutionTime = newMinJobExecutionsTime; + } + } + }, + error: (error: unknown) => { + console.error(error); + } + }) + ); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/index.ts new file mode 100644 index 0000000000..42137f468d --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/data-job-executions-page/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-executions-page.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.html new file mode 100644 index 0000000000..54224f52c9 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.html @@ -0,0 +1,13 @@ + + +
+
+ + + Click and drag to zoom +
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.scss new file mode 100644 index 0000000000..ba73b05d86 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.scss @@ -0,0 +1,22 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +div.duration-chart { + height: 200px; + position: relative; + + button.reset-zoom { + position: absolute; + top: -1rem; + left: 3rem; + } + + span.zoom-tooltip { + position: absolute; + top: -0.8rem; + opacity: 0.5; + left: 2.5rem; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.ts new file mode 100644 index 0000000000..ca9555ecc5 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/execution-duration-chart.component.ts @@ -0,0 +1,316 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DatePipe, formatDate } from '@angular/common'; +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; + +import { Chart, ChartData, registerables, ScatterDataPoint, TimeUnit } from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import 'chartjs-adapter-date-fns'; + +import { CollectionsUtil } from '@vdk/shared'; + +import { DateUtil } from '../../../../../shared/utils'; + +import { DataJobExecutionStatus } from '../../../../../model'; + +import { DataJobExecutionToGridDataJobExecution, GridDataJobExecution } from '../model'; + +type CustomChartData = + ScatterDataPoint + & { startTime: number; duration: number; endTime: number; status: DataJobExecutionStatus; opId: string }; + +@Component({ + selector: 'lib-execution-duration-chart', + templateUrl: './execution-duration-chart.component.html', + styleUrls: ['./execution-duration-chart.component.scss'], + providers: [DatePipe] +}) +export class ExecutionDurationChartComponent implements OnInit, OnChanges { + + @Input() jobExecutions: GridDataJobExecution[] = []; + + chartZoomed = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chart: Chart<'line', any, unknown>; + + constructor() { + Chart.register(...registerables, ChartDataLabels, zoomPlugin); + } + + getChartLabels(): number[] { + return this.jobExecutions + .map((execution) => DateUtil.normalizeToUTC(execution.startTime).getTime()); + } + + getData(min?: number, max?: number): CustomChartData[] { + const divider = this.getDurationUnit().divider; + const executions = CollectionsUtil.isDefined(min) + ? this.jobExecutions.filter((ex) => { + const startTime = DateUtil.normalizeToUTC(ex.startTime).getTime(); + + return startTime >= min && startTime <= max; + }) + : this.jobExecutions + + return executions + .map((execution) => { + return { + startTime: DateUtil.normalizeToUTC(execution.startTime).getTime(), + duration: Math.round((this.getJobDurationSeconds(execution) / divider) * 100) / 100, + endTime: execution.endTime + ? new Date(execution.endTime) + : undefined, + status: execution.status, + opId: execution.opId + } as unknown as CustomChartData; + }); + } + + getMaxDurationSeconds(): number { + return this.jobExecutions + .map((execution) => this.getJobDurationSeconds(execution)) + .reduce((prev, current) => prev > current ? prev : current); + } + + getJobDurationSeconds(execution: GridDataJobExecution) { + return ( + ( + execution.endTime + ? new Date(execution.endTime).getTime() + : new Date(Date.now()).getTime() + ) - new Date(execution.startTime).getTime() + ) / 1000; + } + + getDurationUnit(): { name: string; divider: number } { + const maxDurationSeconds = this.getMaxDurationSeconds(); + + if (maxDurationSeconds > 60) { + return maxDurationSeconds > 3600 + ? { name: 'hours', divider: 3600 } + : { name: 'minutes', divider: 60 }; + } else { + return { name: 'seconds', divider: 1 }; + } + } + + resetZoom() { + this._adjustTimeScaleUnit(null); + this.chartZoomed = false; + this.chart.resetZoom(); + } + + /** + * @inheritDoc + */ + ngOnChanges(changes: SimpleChanges): void { + if (!changes['jobExecutions'].firstChange) { + this.chart.data.labels = this.getChartLabels(); + this.chart.data.datasets[0].data = this.getData(); + this.chart.update(); + } + } + + /** + * @inheritDoc + */ + ngOnInit(): void { + this._initChart(); + } + + private _initChart(): void { + const data: ChartData<'line'> = { + labels: this.getChartLabels(), + datasets: [ + { + data: this.getData(), + fill: false, + pointRadius: 3, + pointBorderColor: (context) => DataJobExecutionToGridDataJobExecution + .resolveColor((context.raw as { status: string }).status), + pointBackgroundColor: (context) => DataJobExecutionToGridDataJobExecution + .resolveColor((context.raw as { status: string }).status), + pointBorderWidth: 3, + parsing: { + xAxisKey: 'startTime', + yAxisKey: 'duration' + } + } + ] + }; + + this.chart = new Chart<'line'>( + 'durationChart', + { + type: 'line', + data, + options: { + showLine: false, + scales: { + x: { + type: 'time', + time: { + unit: this._getTimeScaleUnit(...this._getMinMaxExecutionTuple()) + } + }, + y: { + title: { + display: true, + text: `Duration ${ this.getDurationUnit().name }` + } + } + }, + maintainAspectRatio: false, + plugins: { + zoom: { + zoom: { + drag: { + enabled: true + }, + mode: 'x', + onZoomComplete: (context) => { + this._adjustTimeScaleUnit(context.chart); + this.chartZoomed = true; + } + } + } + , + datalabels: { + display: false + }, + legend: { + display: false + }, + tooltip: { + callbacks: { + label: (context) => { + const rawValues = context.raw as { status: string; endTime: Date }; + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + return `Duration: ${ context.parsed.y }|${ rawValues.status }` + + ( + rawValues.endTime + ? `|End: ${ formatDate(rawValues.endTime, 'MMM d, y, hh:mm:ss a', 'en-US', 'UTC') }` + : '' + ); + } + } + } + } + } + }); + } + + private _adjustTimeScaleUnit(chart: Chart): void { + let unit: TimeUnit; + let min: number = null; + let max: number = null; + + if (CollectionsUtil.isDefined(chart)) { + min = chart.scales['x'].min; + max = chart.scales['x'].max; + + unit = this._getTimeScaleUnit(min, max); + } else { + unit = this._getTimeScaleUnit(...this._getMinMaxExecutionTuple()) + } + + this.chart.options.scales['x'] = { + type: 'time', + time: { + unit + }, + min, + max + }; + + this.chart.data.datasets[0].data = this.getData(min, max); + + this.chart.update(); + } + + private _getMinMaxExecutionTuple(): [number, number] { + const executions = this.getData(); + + if (executions.length < 2) { + return [null, null]; + } + + return [executions[0].startTime, executions[executions.length - 1].startTime] + } + + private _getTimeScaleUnit(min: number | string, max: number | string): TimeUnit { + if (CollectionsUtil.isNil(min) || CollectionsUtil.isNil(max)) { + return 'day'; + } + + const _min = CollectionsUtil.isNumber(min) + ? min + : new Date(min).getTime(); + const _max = CollectionsUtil.isNumber(max) + ? max + : new Date(max).getTime(); + const diff = _max - _min; + + if (diff > (this._getTimeUnitMilliseconds('year') + this._getTimeUnitMilliseconds('second'))) { + return 'year'; + } + + if (diff > (this._getTimeUnitMilliseconds('month') + this._getTimeUnitMilliseconds('second'))) { + return 'month'; + } + + if (diff > (2 * this._getTimeUnitMilliseconds('week'))) { + return 'week'; + } + + if (diff > (this._getTimeUnitMilliseconds('day') + this._getTimeUnitMilliseconds('second'))) { + return 'day'; + } + + if (diff > (this._getTimeUnitMilliseconds('hour') + this._getTimeUnitMilliseconds('second'))) { + return 'hour'; + } + + if (diff > (this._getTimeUnitMilliseconds('minute') + this._getTimeUnitMilliseconds('millisecond'))) { + return 'minute'; + } + + if (diff > (this._getTimeUnitMilliseconds('second') + this._getTimeUnitMilliseconds('millisecond'))) { + return 'second'; + } + + return 'millisecond'; + } + + private _getTimeUnitMilliseconds(unit: 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'): number { + switch (unit) { + case 'millisecond': + return 1; + case 'second': + return 1000; + case 'minute': + return 1000 * 60; + case 'hour': + return 1000 * 60 * 60; + case 'day': + return 1000 * 60 * 60 * 24; + case 'week': + return 1000 * 60 * 60 * 24 * 7; + case 'month': + return 1000 * 60 * 60 * 24 * 31; + case 'year': + return 1000 * 60 * 60 * 24 * 365; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.error(`Taurus DataPipelines ExecutionDurationChartComponent unsupported time format unit ${ unit }`); + + return 0; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/index.ts new file mode 100644 index 0000000000..3e248c888c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-duration-chart/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './execution-duration-chart.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.html new file mode 100644 index 0000000000..7a3cd51faf --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.html @@ -0,0 +1,15 @@ + + +
+ +
+ +
+ {{totalExecutions}}
Executions +
+
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.scss new file mode 100644 index 0000000000..4923c28d5f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.scss @@ -0,0 +1,19 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +div.status-chart { + height: 200px; + position: relative; + margin-top: 15px; + + div.inner-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + font-size: 18px; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.ts new file mode 100644 index 0000000000..aa96522f42 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/execution-status-chart.component.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; + +import { Chart, registerables } from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; + +import { DataJobExecutionToGridDataJobExecution, GridDataJobExecution } from '../model/data-job-execution'; + +@Component({ + selector: 'lib-execution-status-chart', + templateUrl: './execution-status-chart.component.html', + styleUrls: ['./execution-status-chart.component.scss'] +}) +export class ExecutionStatusChartComponent implements OnInit, OnChanges { + + @Input() jobExecutions: GridDataJobExecution[]; + + totalExecutions: number; + chart: Chart; + + constructor() { + Chart.register(...registerables, ChartDataLabels); + } + + getDoughnutLabels(): string[] { + return this.jobExecutions + .map((execution) => execution.status as string) + .filter((item, i, ar) => ar.indexOf(item) === i); + } + + getDoughnutData(): number[] { + const data: number[] = []; + + this.getDoughnutLabels() + .forEach((label) => + data.push( + this.jobExecutions + .filter((execution) => execution.status as string === label) + .length + ) + ); + + return data; + } + + getDoughnutLabelColors(): string[] { + const colors: string[] = []; + const statusColorMap = DataJobExecutionToGridDataJobExecution.getStatusColorsMap(); + + this.getDoughnutLabels() + .forEach((label) => { + colors.push(statusColorMap[label] as string); + }); + + return colors; + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes['jobExecutions'].isFirstChange()) { + this.totalExecutions = this.jobExecutions.length; + this.chart.data.labels = this.getDoughnutLabels(); + this.chart.data.datasets[0].backgroundColor = this.getDoughnutLabelColors(); + this.chart.data.datasets[0].data = this.getDoughnutData(); + this.chart.update(); + } + } + + ngOnInit(): void { + this.totalExecutions = this.jobExecutions.length; + + const data = { + labels: this.getDoughnutLabels(), + datasets: [{ + data: this.getDoughnutData(), + backgroundColor: this.getDoughnutLabelColors(), + hoverOffset: 4 + }] + }; + + this.chart = new Chart( + 'statusChart', + { + type: 'doughnut', + data, + options: { + spacing: 1, + elements: { + arc: { + borderWidth: 0 + } + }, + cutout: 70, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + position: 'left' + }, + datalabels: { + color: 'black', + font: { + size: 16 + } + }, + tooltip: { + xAlign: 'center', + yAlign: 'center' + } + } + } + } + ); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/index.ts new file mode 100644 index 0000000000..2adf2422d3 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/execution-status-chart/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './execution-status-chart.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/index.ts new file mode 100644 index 0000000000..7a6df17dc0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-deployment-details-modal'; +export * from './data-job-execution-status'; +export * from './data-job-execution-status-filter'; +export * from './data-job-execution-type'; +export * from './data-job-execution-type-filter'; +export * from './data-job-executions-grid'; +export * from './data-job-executions-page'; +export * from './execution-duration-chart'; +export * from './execution-status-chart'; +export * from './model'; +export * from './time-period-filter'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/data-job-execution.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/data-job-execution.spec.ts new file mode 100644 index 0000000000..173e4fc0ef --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/data-job-execution.spec.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataJobExecution, DataJobExecutionStatus, DataJobExecutionType } from '../../../../../model'; +import { DataJobExecutionToGridDataJobExecution, GridDataJobExecution } from './data-job-execution'; + +describe('Test DataJobExecutionToGridDataJobExecution', () => { + it('Convert object', () => { + /* eslint-disable @typescript-eslint/naming-convention */ + const expectedObject: GridDataJobExecution = { + jobName: 'test-job', + status: DataJobExecutionStatus.PLATFORM_ERROR, + type: DataJobExecutionType.MANUAL, + startedBy: 'pmitev', + startTime: '2020-12-12T10:10:10Z', + endTime: '2020-12-12T11:11:11Z', + duration: '1h 1m', + message: 'Platform error', + id: '123123123', + opId: '123123123', + jobVersion: 'jkdfgh', + logsUrl: 'https://logs.com', + deployment: { + id: 'test', + jobVersion: 'jkdfgh', + mode: 'release', + vdkVersion: '0.0.1', + deployedDate: '2020-11-11T10:10:10Z', + deployedBy: 'pmitev', + resources: { + memoryLimit: 1000, + memoryRequest: 1000, + cpuLimit: 0.5, + cpuRequest: 0.5 + }, + enabled: true, + schedule: { scheduleCron: '12 * * * *' } + + } + }; + + const dataJobExecution: DataJobExecution = { + jobName: 'test-job', + status: DataJobExecutionStatus.FAILED, + message: 'Platform error', + type: DataJobExecutionType.MANUAL, + id: '123123123', + opId: '123123123', + logsUrl: 'https://logs.com', + startedBy: 'pmitev', + startTime: '2020-12-12T10:10:10Z', + endTime: '2020-12-12T11:11:11Z', + deployment: { + id: 'test', + jobVersion: 'jkdfgh', + mode: 'release', + vdkVersion: '0.0.1', + deployedDate: '2020-11-11T10:10:10Z', + deployedBy: 'pmitev', + resources: { + memoryLimit: 1000, + memoryRequest: 1000, + cpuLimit: 0.5, + cpuRequest: 0.5 + }, + enabled: true, + schedule: { scheduleCron: '12 * * * *' } + } + }; + /* eslint-enable @typescript-eslint/naming-convention */ + + const convertedJobExecution = DataJobExecutionToGridDataJobExecution.convertToDataJobExecution([dataJobExecution]); + + expect(convertedJobExecution.length).toEqual(1); + expect(convertedJobExecution[0]).toEqual(expectedObject); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/data-job-execution.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/data-job-execution.ts new file mode 100644 index 0000000000..ad3eaf0829 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/data-job-execution.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FormatDeltaPipe } from '../../../../../shared/pipes'; + +import { DataJobExecution, DataJobExecutions, DataJobExecutionStatus } from '../../../../../model'; + +export interface GridDataJobExecution extends DataJobExecution { + duration: string; + jobVersion: string; +} + +export class DataJobExecutionToGridDataJobExecution { + static convertStatus(jobStatus: DataJobExecutionStatus, message: string): DataJobExecutionStatus { + switch (`${ jobStatus }`.toUpperCase()) { + case DataJobExecutionStatus.SUCCEEDED: + case DataJobExecutionStatus.FINISHED: + return DataJobExecutionStatus.SUCCEEDED; + case DataJobExecutionStatus.FAILED: + if (message) { + return message === 'Platform error' + ? DataJobExecutionStatus.PLATFORM_ERROR + : DataJobExecutionStatus.USER_ERROR; + } else { + return DataJobExecutionStatus.FAILED; + } + default: + return jobStatus; + } + } + + static convertToDataJobExecution(dataJobExecution: DataJobExecutions): GridDataJobExecution[] { + const formatDeltaPipe = new FormatDeltaPipe(); + + return dataJobExecution.reduce((accumulator, execution) => { + accumulator.push({ + status: DataJobExecutionToGridDataJobExecution.convertStatus(execution.status, execution.message), + type: execution.type, + duration: formatDeltaPipe.transform(execution), + startTime: execution.startTime, + endTime: execution.endTime + ? execution.endTime + : null, + logsUrl: execution.logsUrl, + startedBy: execution.startedBy, + id: execution.id, + jobName: execution.jobName, + opId: execution.opId, + jobVersion: execution.deployment.jobVersion, + deployment: execution.deployment, + message: execution.message + }); + + return accumulator; + }, [] as GridDataJobExecution[]); + } + + static getStatusColorsMap() { + return { + [DataJobExecutionStatus.SUBMITTED]: '#CCCCCC', + [DataJobExecutionStatus.RUNNING]: '#CCCCCC', + [DataJobExecutionStatus.SUCCEEDED]: '#5EB715', + [DataJobExecutionStatus.CANCELLED]: '#CCCCCC', + [DataJobExecutionStatus.SKIPPED]: '#CCCCCC', + [DataJobExecutionStatus.USER_ERROR]: '#F27963', + [DataJobExecutionStatus.PLATFORM_ERROR]: '#F8CF2A' + }; + } + + static resolveColor(key: string): string { + return DataJobExecutionToGridDataJobExecution.getStatusColorsMap()[key] as string; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/index.ts new file mode 100644 index 0000000000..e7dce1b651 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/model/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-execution'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/public-api.ts new file mode 100644 index 0000000000..b6c8429ec9 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/public-api.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-executions-page'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/index.ts new file mode 100644 index 0000000000..cb316623f2 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './time-period-filter.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.html new file mode 100644 index 0000000000..2093f9810e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.html @@ -0,0 +1,103 @@ + + +
+ Time period (UTC): + +
+ + + +

{{ fromTime | date:'MMM d, y, hh:mm:ss a':'UTC'}} to {{ toTime | date:'MMM d, y, hh:mm:ss a':'UTC' }}

+ +
+
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+ +
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.scss new file mode 100644 index 0000000000..0c0b03aee4 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.scss @@ -0,0 +1,592 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +:host { + .time-filter__action-btn { + display: flex; + justify-content: flex-end; + + button { + height: 1.6rem; + line-height: 1.6rem; + margin: 0; + + &:first-child { + margin-right: 0.6rem; + } + } + } + + .time-filter__heading { + font-weight: bold; + margin-right: 10px; + } + + .time-filter__container { + display: flex; + align-items: center; + } + + .time-filter__label { + line-height: 1rem; + } + + ::ng-deep { + clr-signpost { + p { + color: #0a7bd8; + margin: 0; + } + + form { + width: 13rem; + + .clr-date-container { + &.clr-form-control { + &:first-child { + margin-top: 0; + } + } + } + } + + .signpost-content-body { + overflow-y: visible; + } + } + + dp-date-picker { + --date-picker-border-color: rgba(0, 0, 0, .1); + --date-picker-box-shadow: 1px 1px 5px #0000001a; + + --date-picker-background-color: #fff; + --date-picker-selected-background-color: var(--clr-calendar-active-cell-background-color, #d8e3e9); + --date-picker-hover-background-color: var(--clr-calendar-btn-hover-focus-color, #e8e8e8); + --date-picker-text-color: var(--clr-p1-color, #666666); + --date-picker-selected-text-color: var(--clr-calendar-active-cell-color, black); + --date-picker-current-text-color: var(--clr-calendar-today-date-cell-color, black); + + .dp-popup { + background: var(--date-picker-background-color); + border-color: var(--date-picker-border-color); + box-shadow: var(--date-picker-box-shadow); + } + + dp-calendar-nav { + &.dp-material { + .dp-calendar-nav-container { + border: 0; + height: 36px; + } + + .dp-nav-btns-container { + right: 0; + } + + .dp-nav-header { + left: 10px; + } + + .dp-nav-header-btn { + height: 1.8rem; + padding: 0; + margin: 0; + border: 0; + width: auto; + font-size: var(--clr-calendar-picker-btn-font-size, 0.9rem); + font-weight: var(--clr-calendar-picker-btn-font-weight, 200); + line-height: 1.8rem; + border-radius: var(--clr-global-borderradius, 0.15rem); + color: var(--clr-calendar-btn-color, #0072a3); + background: var(--date-picker-background-color); + + &:hover { + background: var(--date-picker-hover-background-color); + } + } + + .dp-calendar-nav-left { + &:before { + left: 2px; + } + } + + .dp-calendar-nav-right { + &:before { + left: -2px; + } + } + + .dp-calendar-nav-left, + .dp-calendar-nav-right { + width: 36px; + height: 36px; + color: var(--clr-calendar-btn-color, #0072a3); + background: var(--date-picker-background-color); + + &:before { + width: 10px; + height: 10px; + } + + &:hover { + background: var(--date-picker-hover-background-color); + } + } + + .dp-current-location-btn { + background: var(--clr-calendar-btn-color, #0072a3); + width: 18px; + height: 18px; + } + } + } + + dp-day-calendar { + &.dp-material { + .dp-calendar-wrapper { + border: 0; + } + + .dp-weekdays { + margin-bottom: 0; + height: 36px; + display: flex; + align-items: center; + } + + .dp-calendar-weekday { + width: 36px; + height: 25px; + text-align: center; + font-size: var(--clr-day-font-size, 0.6rem); + font-weight: 600; + color: var(--clr-p1-color, #666666); + } + + .dp-calendar-day { + height: 1.8rem; + min-width: 1.8rem; + line-height: 1.8rem; + padding: 0; + margin: 0; + border: 0; + border-radius: var(--clr-global-borderradius, 0.15rem); + color: var(--date-picker-text-color); + background: var(--date-picker-background-color); + + &.dp-current-day { + color: var(--date-picker-current-text-color); + font-weight: var(--clr-calendar-today-date-cell-font-weight, 600); + } + + &.dp-selected { + color: var(--date-picker-selected-text-color); + background: var(--date-picker-selected-background-color); + } + + &:hover { + background: var(--date-picker-hover-background-color); + } + + &:disabled { + opacity: .4; + pointer-events: none; + } + } + } + + .dp-day-calendar-container { + background: var(--date-picker-background-color); + } + } + + dp-day-time-calendar { + padding: 0.6rem; + + &.dp-material { + dp-time-select { + border: 0; + + .dp-time-select-controls { + background: var(--date-picker-background-color); + } + + .dp-time-select-control { + width: 36px; + + &.dp-time-select-separator { + width: 5px; + } + } + + .dp-time-select-control-up { + &:before { + top: 2px; + } + } + + .dp-time-select-control-down { + &:before { + top: -2px; + } + } + + .dp-time-select-control-up, + .dp-time-select-control-down { + padding: 0; + margin: 0; + border: 0; + width: 36px; + height: 36px; + border-radius: var(--clr-global-borderradius, 0.15rem); + color: var(--clr-calendar-btn-color, #0072a3); + + &:before { + width: 9px; + height: 9px; + } + + &:hover { + background: var(--date-picker-hover-background-color); + } + } + } + } + } + + dp-month-calendar { + &.dp-material { + .dp-month-calendar-container { + background: var(--date-picker-background-color); + } + + .dp-calendar-month { + height: 2.4rem; + min-width: 2.4rem; + width: 6.3rem; + padding: 0 0.6rem; + margin: 0; + border: 0; + border-radius: var(--clr-global-borderradius, 0.15rem); + cursor: pointer; + font-size: .9rem; + font-weight: 200; + line-height: 1.8rem; + color: var(--date-picker-text-color); + background: var(--date-picker-background-color); + + &.dp-current-month { + color: var(--date-picker-current-text-color); + font-weight: var(--clr-calendar-today-date-cell-font-weight, 600); + } + + &.dp-selected { + color: var(--date-picker-selected-text-color); + background: var(--date-picker-selected-background-color); + } + + &:hover { + background: var(--date-picker-hover-background-color); + } + + &:disabled { + opacity: .4; + pointer-events: none; + } + } + } + } + } + } +} + +// TODO uncomment for v13+ +//::ng-deep .cdk-overlay-container { +// .dp-material { +// &.data-pipelines__executions-filter-picker { +// &.dp-popup { +// --date-picker-border-color: rgba(0, 0, 0, .1); +// --date-picker-box-shadow: 1px 1px 5px #0000001a; +// +// --date-picker-background-color: #fff; +// --date-picker-selected-background-color: var(--clr-calendar-active-cell-background-color, #d8e3e9); +// --date-picker-hover-background-color: var(--clr-calendar-btn-hover-focus-color, #e8e8e8); +// --date-picker-text-color: var(--clr-p1-color, #666666); +// --date-picker-selected-text-color: var(--clr-calendar-active-cell-color, black); +// --date-picker-current-text-color: var(--clr-calendar-today-date-cell-color, black); +// +// background: var(--date-picker-background-color); +// border-color: var(--date-picker-border-color); +// box-shadow: var(--date-picker-box-shadow); +// +// dp-calendar-nav { +// &.dp-material { +// .dp-calendar-nav-container { +// border: 0; +// height: 36px; +// } +// +// .dp-nav-btns-container { +// right: 0; +// } +// +// .dp-nav-header { +// left: 10px; +// } +// +// .dp-nav-header-btn { +// height: 1.8rem; +// padding: 0; +// margin: 0; +// border: 0; +// width: auto; +// font-size: var(--clr-calendar-picker-btn-font-size, 0.9rem); +// font-weight: var(--clr-calendar-picker-btn-font-weight, 200); +// line-height: 1.8rem; +// border-radius: var(--clr-global-borderradius, 0.15rem); +// color: var(--clr-calendar-btn-color, #0072a3); +// background: var(--date-picker-background-color); +// +// &:hover { +// background: var(--date-picker-hover-background-color); +// } +// } +// +// .dp-calendar-nav-left { +// &:before { +// left: 2px; +// } +// } +// +// .dp-calendar-nav-right { +// &:before { +// left: -2px; +// } +// } +// +// .dp-calendar-nav-left, +// .dp-calendar-nav-right { +// width: 36px; +// height: 36px; +// color: var(--clr-calendar-btn-color, #0072a3); +// background: var(--date-picker-background-color); +// +// &:before { +// width: 10px; +// height: 10px; +// } +// +// &:hover { +// background: var(--date-picker-hover-background-color); +// } +// } +// +// .dp-current-location-btn { +// background: var(--clr-calendar-btn-color, #0072a3); +// width: 18px; +// height: 18px; +// } +// } +// } +// +// dp-day-calendar { +// &.dp-material { +// .dp-calendar-wrapper { +// border: 0; +// } +// +// .dp-weekdays { +// margin-bottom: 0; +// height: 36px; +// display: flex; +// align-items: center; +// } +// +// .dp-calendar-weekday { +// width: 36px; +// height: 25px; +// text-align: center; +// font-size: var(--clr-day-font-size, 0.6rem); +// font-weight: 600; +// color: var(--clr-p1-color, #666666); +// } +// +// .dp-calendar-day { +// height: 1.8rem; +// min-width: 1.8rem; +// line-height: 1.8rem; +// padding: 0; +// margin: 0; +// border: 0; +// border-radius: var(--clr-global-borderradius, 0.15rem); +// color: var(--date-picker-text-color); +// background: var(--date-picker-background-color); +// +// &.dp-current-day { +// color: var(--date-picker-current-text-color); +// font-weight: var(--clr-calendar-today-date-cell-font-weight, 600); +// } +// +// &.dp-selected { +// color: var(--date-picker-selected-text-color); +// background: var(--date-picker-selected-background-color); +// } +// +// &:hover { +// background: var(--date-picker-hover-background-color); +// } +// +// &:disabled { +// opacity: .4; +// pointer-events: none; +// } +// } +// } +// +// .dp-day-calendar-container { +// background: var(--date-picker-background-color); +// } +// } +// +// dp-day-time-calendar { +// padding: 0.6rem; +// +// &.dp-material { +// dp-time-select { +// border: 0; +// +// .dp-time-select-controls { +// background: var(--date-picker-background-color); +// } +// +// .dp-time-select-control { +// width: 36px; +// +// &.dp-time-select-separator { +// width: 5px; +// } +// } +// +// .dp-time-select-control-up { +// &:before { +// top: 2px; +// } +// } +// +// .dp-time-select-control-down { +// &:before { +// top: -2px; +// } +// } +// +// .dp-time-select-control-up, +// .dp-time-select-control-down { +// padding: 0; +// margin: 0; +// border: 0; +// width: 36px; +// height: 36px; +// border-radius: var(--clr-global-borderradius, 0.15rem); +// color: var(--clr-calendar-btn-color, #0072a3); +// +// &:before { +// width: 9px; +// height: 9px; +// } +// +// &:hover { +// background: var(--date-picker-hover-background-color); +// } +// } +// } +// } +// } +// +// dp-month-calendar { +// &.dp-material { +// .dp-month-calendar-container { +// background: var(--date-picker-background-color); +// } +// +// .dp-calendar-month { +// height: 2.4rem; +// min-width: 2.4rem; +// width: 6.3rem; +// padding: 0 0.6rem; +// margin: 0; +// border: 0; +// border-radius: var(--clr-global-borderradius, 0.15rem); +// cursor: pointer; +// font-size: .9rem; +// font-weight: 200; +// line-height: 1.8rem; +// color: var(--date-picker-text-color); +// background: var(--date-picker-background-color); +// +// &.dp-current-month { +// color: var(--date-picker-current-text-color); +// font-weight: var(--clr-calendar-today-date-cell-font-weight, 600); +// } +// +// &.dp-selected { +// color: var(--date-picker-selected-text-color); +// background: var(--date-picker-selected-background-color); +// } +// +// &:hover { +// background: var(--date-picker-hover-background-color); +// } +// +// &:disabled { +// opacity: .4; +// pointer-events: none; +// } +// } +// } +// } +// } +// } +// } +//} + +.fade-to-dark { + &.dark { + :host { + ::ng-deep { + dp-date-picker { + --date-picker-border-color: #000; + --date-picker-box-shadow: 0 0.05rem 0.15rem rgb(0 0 0 / 50%); + + --date-picker-background-color: #21333b; + --date-picker-selected-background-color: #324f62; + --date-picker-hover-background-color: #28404d; + --date-picker-text-color: #acbac3; + --date-picker-selected-text-color: #fff; + --date-picker-current-text-color: #fff; + } + } + } + + // TODO uncomment for v13+ + //::ng-deep .cdk-overlay-container { + // .dp-material { + // &.data-pipelines__executions-filter-picker { + // &.dp-popup { + // --date-picker-border-color: #000; + // --date-picker-box-shadow: 0 0.05rem 0.15rem rgb(0 0 0 / 50%); + // + // --date-picker-background-color: #21333b; + // --date-picker-selected-background-color: #324f62; + // --date-picker-hover-background-color: #28404d; + // --date-picker-text-color: #acbac3; + // --date-picker-selected-text-color: #fff; + // --date-picker-current-text-color: #fff; + // } + // } + // } + //} + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.ts new file mode 100644 index 0000000000..39cab55dfb --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/pages/executions/time-period-filter/time-period-filter.component.ts @@ -0,0 +1,289 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; + +import { CalendarValue, DatePickerDirective, IDatePickerDirectiveConfig } from 'ng2-date-picker'; + +// TODO [import dayjs from 'dayjs'] used in ng2-date-picker v13+ instead of moment +import moment from 'moment'; + +import { CollectionsUtil } from '@vdk/shared'; + +type CustomFormGroup = FormGroup & { controls: { [key: string]: FormControl } }; + +@Component({ + selector: 'lib-time-period-filter', + templateUrl: './time-period-filter.component.html', + styleUrls: ['./time-period-filter.component.scss'] +}) +export class TimePeriodFilterComponent implements OnInit, OnDestroy { + @ViewChild('fromPicker') fromPicker: DatePickerDirective; + @ViewChild('toPicker') toPicker: DatePickerDirective; + + @Input() set loading(value: boolean) { + this._loading = value; + } + + get loading() { + return this._loading; + } + + @Output() filterChanged: EventEmitter<{ fromTime: Date; toTime: Date }> = new EventEmitter<{ fromTime: Date; toTime: Date }>(); + + pickerConfig: IDatePickerDirectiveConfig = { + // TODO [format: 'MMM DD, YYYY, hh:mm:ss A',] dayjs + format: 'MMM DD, yyyy, hh:mm:ss A', + showGoToCurrent: true, + showTwentyFourHours: false, + showSeconds: true, + weekDayFormat: 'dd', + numOfMonthRows: 6, + monthBtnFormat: 'MMMM' + }; + fromPickerConfig: IDatePickerDirectiveConfig = { + ...this.pickerConfig + }; + toPickerConfig: IDatePickerDirectiveConfig = { + ...this.pickerConfig + }; + + fromTime: Date; + toTime: Date; + + fromTimeMin: moment.Moment; + fromTimeMax: moment.Moment; + toTimeMin: moment.Moment; + toTimeMax: moment.Moment; + + tmForm: CustomFormGroup; + + @Input() + set minTime(date: Date) { + if (CollectionsUtil.isNil(date)) { + return; + } + + if (!this._initiallySetMin) { + this._initiallySetMin = true; + + this.fromTime = date; + this._minTimeOrig = date; + + const minDateTime = this._normalizeDateTime(date, 'min', 'utc'); + + this.fromTimeMin = minDateTime; + this.fromPickerConfig = { + ...this.fromPickerConfig, + min: this.fromTimeMin + }; + + this.toTimeMin = minDateTime; + this.toPickerConfig = { + ...this.toPickerConfig, + min: this.toTimeMin + }; + + if (!this._isFromPickerOpened) { + this.tmForm + .get('fromDate') + .patchValue( + this._normalizeDateTime(date, 'from', 'utc') + .format(this.fromPickerConfig.format) + ); + } + } + + this._refreshMaxTime(); + } + + private _loading = false; + + private _minTimeOrig: Date; + + private _isFromPickerOpened = false; + private _isToPickerOpened = false; + private _initiallySetMin = false; + private _initiallySetMax = false; + private _refreshIntervalRef: number; + + /** + * ** Constructor. + */ + constructor(private readonly formBuilder: FormBuilder) { + this.tmForm = this.formBuilder.group({ + fromDate: '', + toDate: '' + }) as CustomFormGroup; + } + + onDateTimeChange($event: CalendarValue, type: 'from' | 'to'): void { + if (CollectionsUtil.isNil($event)) { + return; + } + + const emittedDate = $event as moment.Moment; + + if (type === 'from') { + const origMinTimeUtc = this._normalizeDateTime(this._minTimeOrig, 'from', 'utc'); + if (emittedDate.isBefore(origMinTimeUtc) || emittedDate.isAfter(this.fromTimeMax)) { + return; + } + + this.fromTime = new Date(this._normalizeDateTime(emittedDate, 'from', 'timezone').valueOf()); + + this.toTimeMin = this._normalizeDateTime(emittedDate, 'min'); + this.toPickerConfig = { + ...this.toPickerConfig, + min: this.toTimeMin + }; + } else { + if (emittedDate.isBefore(this.toTimeMin) || emittedDate.isAfter(this.toTimeMax)) { + return; + } + + this.toTime = new Date(this._normalizeDateTime(emittedDate, 'to', 'timezone').valueOf()); + + this.fromTimeMax = this._normalizeDateTime(emittedDate, 'max'); + this.fromPickerConfig = { + ...this.fromPickerConfig, + max: this.fromTimeMax + }; + } + } + + togglePicker($event: MouseEvent, type: 'from' | 'to'): void { + $event.preventDefault(); + + if (type === 'from') { + if (this._isFromPickerOpened) { + this.fromPicker.api.close(); + } else { + this.fromPicker.api.open(); + } + } else { + if (this._isToPickerOpened) { + this.toPicker.api.close(); + } else { + this.toPicker.api.open(); + } + } + } + + onPickerOpened(type: 'from' | 'to', isOpened: boolean): void { + if (type === 'from') { + this._isFromPickerOpened = isOpened; + } else { + this._isToPickerOpened = isOpened; + } + } + + submitForm($event: Event): void { + $event.preventDefault(); + + this._emitChanges(); + } + + clearFilter($event: MouseEvent): void { + $event.preventDefault(); + + this._initiallySetMin = false; + this._initiallySetMax = false; + + this.fromTime = null; + this.toTime = null; + + this._emitChanges(); + + this.minTime = this._minTimeOrig; + } + + /** + * @inheritDoc + */ + ngOnInit(): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this._refreshIntervalRef = setInterval(this._refreshMaxTime.bind(this), 15 * 1000); // Update max time every 15s + } + + /** + * @inheritDoc + */ + ngOnDestroy(): void { + if (this._refreshIntervalRef) { + clearInterval(this._refreshIntervalRef); + } + } + + private _refreshMaxTime() { + const date = new Date(); + + this.toTimeMax = this._normalizeDateTime(date, 'max', 'utc'); + this.toPickerConfig = { + ...this.toPickerConfig, + max: this.toTimeMax + }; + + if (!this._initiallySetMax) { + this._initiallySetMax = true; + + this.toTime = date; + + this.fromTimeMax = this._normalizeDateTime(date, 'max', 'utc'); + this.fromPickerConfig = { + ...this.fromPickerConfig, + max: this.fromTimeMax + }; + + if (!this._isToPickerOpened) { + this.tmForm + .get('toDate') + .patchValue( + this._normalizeDateTime(date, 'to', 'utc') + .format(this.toPickerConfig.format) + ); + } + } + } + + private _normalizeDateTime(date: Date | moment.Moment, + type: 'min' | 'max' | 'from' | 'to', + travel: 'utc' | 'timezone' = null): moment.Moment { + + const offset = moment().utcOffset(); + + let dtInstance = moment(date).millisecond(0); + + if (travel) { + if (offset > 0) { + dtInstance = travel === 'utc' + ? dtInstance.subtract(offset, 'm') + : dtInstance.add(offset, 'm'); + } else if (offset < 0) { + dtInstance = travel === 'utc' + ? dtInstance.add(-offset, 'm') + : dtInstance.subtract(-offset, 'm'); + } + } + + if (type === 'min') { + dtInstance = dtInstance.subtract(1, 'ms'); + } else if (type === 'max') { + dtInstance = dtInstance.add(1, 'ms'); + } else if (type === 'to') { + dtInstance = dtInstance.millisecond(999); + } + + return dtInstance; + } + + private _emitChanges(): void { + this.filterChanged.emit({ + fromTime: this.fromTime, + toTime: this.toTime + }); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/public-api.ts new file mode 100644 index 0000000000..64ec436784 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-job/public-api.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-page.component'; +export * from './pages/details/public-api'; +export * from './pages/executions/public-api'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.html new file mode 100644 index 0000000000..8eba538ca0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.html @@ -0,0 +1,297 @@ + + + + +
+ + +
+ + +
+ +
+ +
+ + + Job name + + + Team name + + + + + Description + + + + + + Deployment Status + + + + + + + + Last Execution End (UTC) + + + + Last Execution + Duration + + + + Last Execution Status + + + + + + + + + + + + + + Success rate + + + + + Calculating up to 336 executions from last 14 days + + + + + Schedule (UTC) + + + Next run (UTC) + + + + + Last Deployed + (UTC) + + + + Last Deployed By + + + + + Source + + + + + Logs + + + + View + + + + + + {{job.jobName}} + + + {{job.jobName}} + + + + + + {{job.config.team}} + + + + {{job.config?.description | words: 8}} + + + + + + + {{ job.deployments ? (job.deployments[0]?.lastExecutionTime | date:'MMM d, y, hh:mm a':'utc') : null }} + + + + {{ job.deployments ? ((job.deployments[0]?.lastExecutionDuration) | formatDuration) : null }} + + + + + + + {{job.deployments | executionSuccessRate}} + + {{ job.config?.schedule?.scheduleCron | formatSchedule:'' }} + + + + {{ job.config?.schedule?.nextRunEpochSeconds | parseEpoch | date: 'MMM d, y, hh:mm a':'UTC' }} + + + + + {{ job.deployments && job.deployments[0]?.lastDeployedDate ? (job.deployments[0]?.lastDeployedDate | date: 'MMM d, y, hh:mm a':'UTC') : null }} + + + + {{ job.deployments ? (job.deployments[0]?.lastDeployedBy) : null }} + + + +
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + +
+ + +
+ No data jobs created! +
+
+ No data jobs that match with + {{searchQueryValue}} criteria +
+
+ + + + + Data Jobs per page + {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} of {{pagination.totalItems}} Data Jobs + + + +
+
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.scss new file mode 100644 index 0000000000..7c43e1b820 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.scss @@ -0,0 +1,12 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@import "../../../base-grid/data-jobs-base-grid.component"; + +.label-link-suppress-decoration { + &:hover { + text-decoration: none; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.spec.ts new file mode 100644 index 0000000000..19ea29290b --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.spec.ts @@ -0,0 +1,320 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Location } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; + +import { ClrDatagridStateInterface } from '@clr/angular'; + +import { ApolloQueryResult } from '@apollo/client/core'; + +import { + ASC, + CallFake, + ComponentModel, + ComponentService, + ComponentStateImpl, + ErrorHandlerService, + NavigationService, + RouterService, + RouterState, + SystemEventDispatcher, + URLStateManager +} from '@vdk/shared'; + +import { DATA_PIPELINES_CONFIGS, DataJobDetails, DataJobPage, DisplayMode } from '../../../../model'; + +import { DataJobsApiService, DataJobsService } from '../../../../services'; + +import { DataJobsExploreGridComponent } from './data-jobs-explore-grid.component'; +import { QUERY_PARAM_SEARCH } from '../../../base-grid/data-jobs-base-grid.component'; + +describe('DataJobsExploreGridComponent', () => { + let componentServiceStub: jasmine.SpyObj; + let dataJobsApiServiceStub: jasmine.SpyObj; + let locationStub: jasmine.SpyObj; + let navigationServiceStub: jasmine.SpyObj; + let routerServiceStub: jasmine.SpyObj; + let dataJobsServiceStub: jasmine.SpyObj; + let errorHandlerServiceStub: jasmine.SpyObj; + + let componentModelStub: ComponentModel; + let component: DataJobsExploreGridComponent; + let fixture: ComponentFixture; + + const TEST_JOB = { + jobName: 'job001' + }; + + beforeAll(() => { + // This hack is needed in order to prevent tests to reload browser. + // Browser reloading stops Tests execution in the same browser, and need to restart execution from beginning. + window.onbeforeunload = () => 'Stop browser from reload!'; + }); + + beforeEach(() => { + const activatedRouteStub = () => ({ + queryParams: { + subscribe: CallFake + }, + snapshot: null + }); + const routerStub = () => ({ + url: '/explore/data-jobs' + }); + + componentServiceStub = jasmine.createSpyObj('componentService', ['init', 'getModel', 'idle', 'update']); + navigationServiceStub = jasmine.createSpyObj('navigationService', ['navigate', 'navigateTo', 'navigateBack']); + dataJobsApiServiceStub = jasmine.createSpyObj( + 'dataJobsService', + ['getJobs', 'getJobDetails', 'getJobExecutions'] + ); + dataJobsServiceStub = jasmine.createSpyObj('dataJobsService', [ + 'loadJobs', + 'notifyForRunningJobExecutionId', + 'notifyForJobExecutions', + 'notifyForTeamImplicitly', + 'getNotifiedForRunningJobExecutionId', + 'getNotifiedForJobExecutions', + 'getNotifiedForTeamImplicitly' + ]); + locationStub = jasmine.createSpyObj('location', ['path', 'go']); + routerServiceStub = jasmine.createSpyObj('routerService', ['getState', 'get']); + errorHandlerServiceStub = jasmine.createSpyObj('errorHandlerService', [ + 'processError', + 'handleError' + ]); + + dataJobsServiceStub.getNotifiedForJobExecutions.and.returnValue(new Subject()); + dataJobsServiceStub.getNotifiedForTeamImplicitly.and.returnValue(new BehaviorSubject('taurus')); + + componentModelStub = ComponentModel.of( + ComponentStateImpl.of({}), + RouterState.empty() + ); + routerServiceStub.getState.and.returnValue(new Subject()); + routerServiceStub.get.and.returnValue(new Subject()); + componentServiceStub.init.and.returnValue(of(componentModelStub)); + componentServiceStub.getModel.and.returnValue(of(componentModelStub)); + + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [DataJobsExploreGridComponent], + providers: [ + { + provide: DATA_PIPELINES_CONFIGS, + useFactory: () => ({ + defaultOwnerTeamName: 'all' + }) + }, + { provide: RouterService, useValue: routerServiceStub }, + { provide: ComponentService, useValue: componentServiceStub }, + { provide: NavigationService, useValue: navigationServiceStub }, + { provide: ActivatedRoute, useFactory: activatedRouteStub }, + { provide: DataJobsApiService, useValue: dataJobsApiServiceStub }, + { provide: DataJobsService, useValue: dataJobsServiceStub }, + { provide: Location, useValue: locationStub }, + { provide: Router, useFactory: routerStub }, + { provide: ErrorHandlerService, useValue: errorHandlerServiceStub } + ] + }); + + fixture = TestBed.createComponent(DataJobsExploreGridComponent); + component = fixture.componentInstance; + component.teamNameFilter = 'testFilterTeam'; + component.model = componentModelStub; + + dataJobsApiServiceStub.getJobs.and.returnValue(of({ + data: { + content: [], + totalItems: 0 + } + } as ApolloQueryResult)); + dataJobsApiServiceStub.getJobDetails.and.returnValue(of(null) as Observable); + dataJobsApiServiceStub.getJobExecutions.and.returnValue(of({ content: [], totalItems: 0, totalPages: 0 })); + + locationStub.path.and.returnValue('/explore/data-jobs'); + + spyOn(SystemEventDispatcher, 'send').and.returnValue(Promise.resolve(true)); + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + it(`displayMode has default value`, () => { + expect(component.displayMode).toEqual(DisplayMode.STANDARD); + }); + + it(`loading has default value`, () => { + expect(component.loading).toBeFalse(); + }); + + it(`dataJobs has default value`, () => { + expect(component.dataJobs).toEqual([]); + }); + + it(`totalJobs has default value`, () => { + expect(component.totalJobs).toEqual(0); + }); + + describe('urlUpdateStrategy', () => { + it('should verify the behaviour of _doUrlUpdate when urlUpdateStrategy is default (updateRouter)', () => { + const navigateToUrlSpy = spyOn(component.urlStateManager, 'navigateToUrl').and.returnValue(Promise.resolve(true)); + + // @ts-ignore + component._doUrlUpdate(); + + expect(navigateToUrlSpy).toHaveBeenCalled(); + }); + + it('should verify the behaviour of _doUrlUpdate when urlUpdateStrategy is changed (updateLocation)', () => { + const locationToURLSpy = spyOn(component.urlStateManager, 'locationToURL').and.callFake(CallFake); + component.urlUpdateStrategy = 'updateLocation'; + + // @ts-ignore + component._doUrlUpdate(); + + expect(locationToURLSpy).toHaveBeenCalled(); + }); + }); + + describe('urlStateManager', () => { + it('should verify will invoke default urlStateManager (locally created)', () => { + // Given + const setQueryParamSpy = spyOn(component.urlStateManager, 'setQueryParam').and.callFake(CallFake); + + // When + component.search('search'); + + // Then + expect(setQueryParamSpy).toHaveBeenCalledWith(QUERY_PARAM_SEARCH, 'search'); + }); + + it('should verify will invoke external urlStateManager (dependency injected)', () => { + // Given + const urlStateManagerStub = new URLStateManager('baseUrl', locationStub); + const setQueryParamSpy = spyOn(urlStateManagerStub, 'setQueryParam').and.callFake(CallFake); + + // When + component.urlStateManager = urlStateManagerStub; + component.search('search'); + + // Then + expect(setQueryParamSpy).toHaveBeenCalledWith(QUERY_PARAM_SEARCH, 'search'); + }); + + it('should verify will invoke default urlStateManager (dependency injection is null or undefined)', () => { + // Given + const setQueryParamSpy = spyOn(component.urlStateManager, 'setQueryParam').and.callFake(CallFake); + + // When + component.urlStateManager = null; + component.search('search test value 1'); + component.urlStateManager = undefined; + component.search('search test value 2'); + + // Then + expect(setQueryParamSpy.calls.argsFor(0)).toEqual([QUERY_PARAM_SEARCH, 'search test value 1']); + expect(setQueryParamSpy.calls.argsFor(1)).toEqual([QUERY_PARAM_SEARCH, 'search test value 2']); + }); + }); + + describe('handleStateChange', () => { + it('makes expected calls', () => { + const clrDatagridStateInterfaceStub = { filters: [] } as ClrDatagridStateInterface; + clrDatagridStateInterfaceStub.filters.push({ + property: 'search_prop', + pattern: '%search%', + sort: ASC + }); + component.gridState = clrDatagridStateInterfaceStub; + + // @ts-ignore + spyOn(component, '_getFilterPattern').and.callThrough(); + // @ts-ignore + component._doLoadData(); + // @ts-ignore + expect(component._getFilterPattern).toHaveBeenCalled(); + expect(dataJobsServiceStub.loadJobs).toHaveBeenCalled(); + }); + }); + + describe('viewJobDetails', () => { + it('skips viewJobDetails for undefined job', () => { + // Given + const navigateToSpy = spyOn(component, 'navigateTo').and.callFake(CallFake); + + // When + component.navigateToJobDetails(); + + // Then + expect(component.selectedJob).toBeUndefined(); + expect(navigateToSpy).not.toHaveBeenCalled(); + }); + + it('opens viewJobDetails for valid job', () => { + // Given + const navigateToSpy = spyOn(component, 'navigateTo').and.returnValue(Promise.resolve(true)); + component.ngOnInit(); + + // When + component.navigateToJobDetails({ jobName: 'job001', config: { team: 'team007' } }); + + // Then + expect(component.selectedJob).toBeDefined(); + expect(navigateToSpy).toHaveBeenCalledWith({ + '$.team': 'team007', + '$.job': 'job001' + }); + }); + }); + + describe('ngOnInit', () => { + it('makes expected calls', () => { + // @ts-ignore + component.ngOnInit(); + expect(componentServiceStub.init).toHaveBeenCalled(); + expect(componentServiceStub.getModel).toHaveBeenCalled(); + }); + }); + + describe('isStandardDisplayMode', () => { + it('returns expected displayMode with COMPACT', () => { + component.displayMode = DisplayMode.COMPACT; + expect(component.isStandardDisplayMode()).toBeFalse(); + }); + + it('returns expected displayMode with null', () => { + component.displayMode = null; + expect(component.isStandardDisplayMode()).toBeFalse(); + }); + + it('returns expected displayMode with STANDARD', () => { + component.displayMode = DisplayMode.STANDARD; + expect(component.isStandardDisplayMode()).toBeTrue(); + }); + }); + + describe('selectionChanged', () => { + it('sets expected dataJob', () => { + component.selectionChanged(TEST_JOB); + expect(component.selectedJob).toEqual(TEST_JOB); + }); + }); + + describe('search', () => { + it('makes expected calls', () => { + spyOn(component, 'loadDataWithState').and.callThrough(); + component.search('searchValue'); + expect(component.searchQueryValue).toBe('searchValue'); + expect(component.loadDataWithState).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.ts new file mode 100644 index 0000000000..0144aba333 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/data-jobs-explore-grid.component.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, ElementRef, Inject, Input, OnInit } from '@angular/core'; +import { DOCUMENT, Location } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ComponentService, ErrorHandlerService, NavigationService, RouterService } from '@vdk/shared'; + +import { DATA_PIPELINES_CONFIGS, DataPipelinesConfig, DisplayMode } from '../../../../model'; +import { DataJobsApiService, DataJobsService } from '../../../../services'; + +import { DataJobsBaseGridComponent } from '../../../base-grid/data-jobs-base-grid.component'; + +@Component({ + selector: 'lib-data-jobs-explore-grid', + templateUrl: './data-jobs-explore-grid.component.html', + styleUrls: ['./data-jobs-explore-grid.component.scss'] +}) +export class DataJobsExploreGridComponent extends DataJobsBaseGridComponent implements OnInit { + //Decorators are not inherited in Angular. If we need @Input() we need to declare it here + @Input() override teamNameFilter: string; + @Input() override displayMode: DisplayMode; + + readonly uuid = 'DataJobsExploreGridComponent'; + + constructor( // NOSONAR + componentService: ComponentService, + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + routerService: RouterService, + dataJobsService: DataJobsService, + dataJobsApiService: DataJobsApiService, + errorHandlerService: ErrorHandlerService, + location: Location, + router: Router, + elementRef: ElementRef, + @Inject(DOCUMENT) document: Document, + @Inject(DATA_PIPELINES_CONFIGS) dataPipelinesModuleConfig: DataPipelinesConfig + ) { + + super( + componentService, + navigationService, + activatedRoute, + routerService, + dataJobsService, + dataJobsApiService, + errorHandlerService, + location, + router, + elementRef, + document, + dataPipelinesModuleConfig, + 'explore_data_jobs_grid_user_config', + { + hiddenColumns: { + description: true, + lastExecutionDuration: true, + successRate: true, + nextRun: true, + lastDeployedDate: true, + lastDeployedBy: true, + source: true, + logsUrl: true + } + }); + } + + showTeamsColumn() { + return (this.dataPipelinesModuleConfig?.exploreConfig?.showTeamsColumn); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/index.ts new file mode 100644 index 0000000000..6b664a68ad --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/components/grid/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-explore-grid.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.css b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.css new file mode 100644 index 0000000000..8ef7384c28 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.css @@ -0,0 +1,10 @@ +/* + * Copyright 2023-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.page-container { + display: grid; + height: 100%; + grid-template-rows: 40px 1fr; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.html new file mode 100644 index 0000000000..926051c5ab --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.html @@ -0,0 +1,11 @@ + + +
+
+

Explore Data Jobs

+
+ +
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.spec.ts new file mode 100644 index 0000000000..00746c00db --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.spec.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DataJobsExplorePageComponent } from './data-jobs-explore-page.component'; + +describe('DataJobsExploreComponent', () => { + let component: DataJobsExplorePageComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [DataJobsExplorePageComponent] + }); + fixture = TestBed.createComponent(DataJobsExplorePageComponent); + component = fixture.componentInstance; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.ts new file mode 100644 index 0000000000..43e48ac322 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/data-jobs-explore-page.component.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib-data-jobs-explore', + templateUrl: './data-jobs-explore-page.component.html', + styleUrls: ['./data-jobs-explore-page.component.css'] +}) +export class DataJobsExplorePageComponent { +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/index.ts new file mode 100644 index 0000000000..672e303224 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-explore-page.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/public-api.ts new file mode 100644 index 0000000000..338ef89baa --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-explore/public-api.ts @@ -0,0 +1,7 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './index'; +export * from './components/grid'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.html new file mode 100644 index 0000000000..0f3dff257f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.html @@ -0,0 +1,350 @@ + + + + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + + Job name + + + Team name + + + + + Description + + + + + + Deployment Status + + + + + + + + Last Execution End (UTC) + + + + Last Execution + Duration + + + + Last Execution Status + + + + + + + + + + + + + + Success rate + + + + + Calculating up to 336 executions from last 14 days + + + + + Schedule (in UTC) + + + Next run (UTC) + + + + + Last Deployed + (UTC) + + + + Last Deployed By + + + + + Notifications + + + + + Source + + + + + Logs + + + + Details + + + + {{job.jobName}} + + + {{job.config?.team}} + + + {{job.config?.description | words: 8}} + + + + + + + + {{ job.deployments ? (job.deployments[0]?.lastExecutionTime | date:'MMM d, y, hh:mm a':'utc') : null }} + + + + {{ job.deployments ? ((job.deployments[0]?.lastExecutionDuration) | formatDuration) : null }} + + + + + + + {{job.deployments | executionSuccessRate}} + + + {{ job.config?.schedule?.scheduleCron | formatSchedule:'' }} + + + + + {{ job.config?.schedule?.nextRunEpochSeconds | parseEpoch | date: 'MMM d, y, hh:mm a':'UTC' }} + + + + + {{ job.deployments && job.deployments[0]?.lastDeployedDate ? (job.deployments[0]?.lastDeployedDate | date: 'MMM d, y, hh:mm a':'UTC') : null }} + + + + {{ job.deployments ? (job.deployments[0]?.lastDeployedBy) : null }} + + + + + + + + +
+
+
+ + + +
+
+
+ + + + + + + + + + + + +
+ + +
+
+ No data jobs created! +
+ Learn about Data Jobs +
+
+ No data jobs that match with + {{searchQueryValue}} criteria +
+
+ + + + + Data Jobs per page + {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} of {{pagination.totalItems}} Data Jobs + + + +
+
+
+ + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.scss new file mode 100644 index 0000000000..fdc1f842e1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.scss @@ -0,0 +1,14 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@import "../../../base-grid/data-jobs-base-grid.component"; + +clr-datagrid clr-dg-placeholder { + .msg-btn-placeholder { + display: flex; + flex-direction: column; + align-items: center; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.spec.ts new file mode 100644 index 0000000000..91b7417a34 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.spec.ts @@ -0,0 +1,417 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { of, Subject } from 'rxjs'; + +import { ClrDatagridStateInterface } from '@clr/angular'; + +import { + ASC, + CallFake, + ComponentModel, + ComponentService, + ComponentStateImpl, + DESC, + ErrorHandlerService, + NavigationService, + RouterService, + RouterState, + RouteState, + ToastService +} from '@vdk/shared'; + +import { ExtractJobStatusPipe } from '../../../../shared/pipes'; + +import { DataJobsApiService, DataJobsService } from '../../../../services'; + +import { DATA_PIPELINES_CONFIGS, DisplayMode } from '../../../../model'; + +import { DataJobsManageGridComponent } from './data-jobs-manage-grid.component'; + +describe('DataJobsManageGridComponent', () => { + let componentServiceStub: jasmine.SpyObj; + let navigationServiceStub: jasmine.SpyObj; + let routerServiceStub: jasmine.SpyObj; + let dataJobsServiceStub: jasmine.SpyObj; + let toastServiceStub: jasmine.SpyObj; + let errorHandlerServiceStub: jasmine.SpyObj; + + let componentModelStub: ComponentModel; + let component: DataJobsManageGridComponent; + let fixture: ComponentFixture; + + const TEST_DEPLOYMENT = { + id: 'id001', + enabled: true + }; + + const TEST_JOB = { + jobName: 'job001', + config: { + description: 'description001', + team: 'testTeam' + }, + deployments: [ + TEST_DEPLOYMENT + ] + }; + + let teamSubject: Subject; + let teamSubjectSubscribeSpy: jasmine.Spy; + + const EMPTY_TEAM_NAME_FILTER = ''; + + beforeAll(() => { + // This hack is needed in order to prevent tests to reload browser. + // Browser reloading stops Tests execution in the same browser, and need to restart execution from beginning. + window.onbeforeunload = () => 'Stop browser from reload!'; + }); + + beforeEach(() => { + componentServiceStub = jasmine.createSpyObj('componentService', ['init', 'getModel', 'idle', 'update']); + navigationServiceStub = jasmine.createSpyObj('navigationService', ['navigateTo', 'navigateBack']); + routerServiceStub = jasmine.createSpyObj('routerService', ['getState', 'get']); + dataJobsServiceStub = jasmine.createSpyObj('dataJobsService', [ + 'loadJobs', + 'notifyForRunningJobExecutionId', + 'notifyForJobExecutions', + 'notifyForTeamImplicitly', + 'getNotifiedForRunningJobExecutionId', + 'getNotifiedForJobExecutions', + 'getNotifiedForTeamImplicitly' + ]); + toastServiceStub = jasmine.createSpyObj('toastService', ['show']); + errorHandlerServiceStub = jasmine.createSpyObj('errorHandlerService', [ + 'processError', + 'handleError' + ]); + + teamSubject = new Subject(); + teamSubjectSubscribeSpy = spyOn(teamSubject, 'subscribe').and.callThrough(); + + const activatedRouteStub = () => ({ + queryParams: { + subscribe: CallFake + }, + snapshot: null + }); + const dataJobsApiServiceStub = () => ({ + getAllJobs: () => ({ + subscribe: CallFake + }), + getJobDetails: () => ({ + subscribe: () => CallFake + }), + getJobExecutions: () => ({ + subscribe: () => CallFake + }), + updateDataJobStatus: () => ({ + subscribe: () => { + return new Subject(); + } + }), + executeDataJob: () => ({ + subscribe: () => { + return new Subject(); + } + }) + }); + const locationStub = () => ({ + path: () => 'Test', + go: () => CallFake + }); + const routerStub = () => ({ + url: '/explore/data-jobs' + }); + + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [ + DataJobsManageGridComponent, + ExtractJobStatusPipe + ], + providers: [ + { + provide: DATA_PIPELINES_CONFIGS, + useFactory: () => ({ + defaultOwnerTeamName: 'all', + manageConfig: { + filterByTeamName: true, + showTeamsColumn: true, + allowExecuteNow: true, + displayMode: DisplayMode.COMPACT, + selectedTeamNameObservable: teamSubject + } + }) + }, + { provide: RouterService, useValue: routerServiceStub }, + { provide: ComponentService, useValue: componentServiceStub }, + { provide: NavigationService, useValue: navigationServiceStub }, + { provide: ActivatedRoute, useFactory: activatedRouteStub }, + { provide: DataJobsService, useValue: dataJobsServiceStub }, + { provide: ToastService, useValue: toastServiceStub }, + { provide: DataJobsApiService, useFactory: dataJobsApiServiceStub }, + { provide: Location, useFactory: locationStub }, + { provide: Router, useFactory: routerStub }, + { provide: ErrorHandlerService, useValue: errorHandlerServiceStub } + + ] + }); + + componentModelStub = ComponentModel.of( + ComponentStateImpl.of({}), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + componentServiceStub.init.and.returnValue(of(componentModelStub)); + componentServiceStub.getModel.and.returnValue(of(componentModelStub)); + routerServiceStub.getState.and.returnValue(new Subject()); + routerServiceStub.get.and.returnValue(new Subject()); + + fixture = TestBed.createComponent(DataJobsManageGridComponent); + component = fixture.componentInstance; + component.selectedJob = TEST_JOB; + component.model = componentModelStub; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + describe('editJob', () => { + it('skips editJob for undefined job', () => { + // Given + const navigateToSpy = spyOn(component, 'navigateTo').and.callFake(CallFake); + component.selectedJob = null; + + // When + component.navigateToJobDetails(); + + // Then + expect(component.selectedJob).toBeNull(); + expect(navigateToSpy).not.toHaveBeenCalled(); + }); + + it('opens editJob for valid job', () => { + // Given + const navigateToSpy = spyOn(component, 'navigateTo').and.returnValue(Promise.resolve(true)); + component.selectedJob = null; + component.ngOnInit(); + + // When + component.navigateToJobDetails(TEST_JOB); + + // Then + expect(component.selectedJob).toBeDefined(); + expect(navigateToSpy).toHaveBeenCalledWith({ + '$.team': 'testTeam', + '$.job': 'job001' + }); + }); + }); + + describe('ngOnInit', () => { + it('makes expected calls', () => { + component.ngOnInit(); + expect(componentServiceStub.init).toHaveBeenCalled(); + expect(componentServiceStub.getModel).toHaveBeenCalled(); + }); + }); + + describe('showTeamsColumn', () => { + it('returns correct value', () => { + expect(component.showTeamsColumn()).toBeTrue(); + }); + }); + + describe('onJobStatusChange', () => { + it('makes expected calls for empty SelectedJobDeployment', () => { + component.selectedJob.deployments = []; + spyOn(component, 'extractSelectedJobDeployment').and.callThrough(); + spyOn(console, 'log').and.callThrough(); + component.onJobStatusChange(); + expect(component.extractSelectedJobDeployment).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalled(); + }); + + it('makes expected calls for valid SelectedJobDeployment', () => { + component.selectedJob.deployments = [TEST_DEPLOYMENT]; + const dataJobSServiceStub: DataJobsApiService = fixture.debugElement.injector.get( + DataJobsApiService + ); + spyOn(component, 'extractSelectedJobDeployment').and.callThrough(); + spyOn(dataJobSServiceStub, 'updateDataJobStatus').and.callThrough(); + component.onJobStatusChange(); + expect(component.extractSelectedJobDeployment).toHaveBeenCalled(); + expect(dataJobSServiceStub.updateDataJobStatus).toHaveBeenCalled(); + }); + }); + + describe('onExecuteDataJob', () => { + it('makes expected calls', () => { + component.selectedJob = TEST_JOB; + component.selectedJob.deployments = [TEST_DEPLOYMENT]; + const dataJobSServiceStub: DataJobsApiService = fixture.debugElement.injector.get( + DataJobsApiService + ); + spyOn(component, 'extractSelectedJobDeployment').and.callThrough(); + spyOn(dataJobSServiceStub, 'executeDataJob').and.callThrough(); + component.onExecuteDataJob(); + expect(component.extractSelectedJobDeployment).toHaveBeenCalled(); + expect(dataJobSServiceStub.executeDataJob).toHaveBeenCalled(); + }); + }); + + describe('executeDataJob', () => { + it('sets the expected options', () => { + component.executeDataJob(); + expect(component.confirmExecuteNowOptions.message).toBeDefined(); + expect(component.confirmExecuteNowOptions.infoText).toBeDefined(); + expect(component.confirmExecuteNowOptions.opened).toBeTrue(); + }); + }); + + describe('enable', () => { + it('sets the expected options', () => { + component.enable(); + expect(component.confirmStatusOptions.message).toBeDefined(); + expect(component.confirmStatusOptions.infoText).toBeDefined(); + expect(component.confirmStatusOptions.opened).toBeTrue(); + }); + }); + + describe('disable', () => { + it('sets the expected options', () => { + component.disable(); + expect(component.confirmStatusOptions.message).toBeDefined(); + expect(component.confirmStatusOptions.infoText).toBeDefined(); + expect(component.confirmStatusOptions.opened).toBeTrue(); + }); + }); + + describe('component initialization', () => { + it('reads manageConfig filterByTeamName properly', () => { + expect(component.filterByTeamName).toBeTrue(); + }); + + it('reads manageConfig displayMode properly', () => { + expect(component.displayMode).toEqual(DisplayMode.COMPACT); + }); + + it('reads manageConfig selectedTeamNameObservable properly', () => { + expect(teamSubjectSubscribeSpy).toHaveBeenCalled(); + }); + }); + + describe('resetTeamNameFilter', () => { + it('resets the TeamNameFilter', () => { + component.resetTeamNameFilter(); + expect(component.teamNameFilter).toEqual(EMPTY_TEAM_NAME_FILTER); + }); + }); + + describe('Methods::', () => { + describe('|loadDataWithState|', () => { + it('should verify will append on model team filter when default team exist', fakeAsync(() => { + // Given + const state: ClrDatagridStateInterface = { + filters: null, + sort: null, + page: { + size: 10, + current: 2 + } + }; + component.ngOnInit(); + teamSubject.next('teamA'); + + tick(100); + + // When + component.loadDataWithState(state); + + tick(600); + + // Then + const filter = componentModelStub.getComponentState().filter; + expect(filter.criteria.length).toEqual(1); + expect(filter.criteria[0]).toEqual({ + property: 'config.team', + pattern: 'teamA', + sort: null + }); + })); + + it('should verify will append on model filter and sorting and default team filter', fakeAsync(() => { + // Given + const state: ClrDatagridStateInterface = { + filters: [ + { property: 'deployments.enabled', value: 'true' } + ], + sort: { by: 'jobName', reverse: false }, + page: { + size: 10, + current: 2 + } + }; + component.ngOnInit(); + teamSubject.next('teamB'); + + tick(100); + + // When + component.loadDataWithState(state); + + tick(600); + + // Then + const filter = componentModelStub.getComponentState().filter; + expect(filter.criteria).toEqual([ + { property: 'config.team', pattern: 'teamB', sort: null }, + { property: 'deployments.enabled', pattern: 'true', sort: null }, + { property: 'jobName', pattern: null, sort: ASC } + ]); + })); + + it('should verify will append on model filter and sorting and no default team filter', fakeAsync(() => { + // Given + const state: ClrDatagridStateInterface = { + filters: [ + { property: 'deployments.enabled', value: 'false' } + ], + sort: { by: 'config.description', reverse: true }, + page: { + size: 10, + current: 2 + } + }; + component.filterByTeamName = false; + component.ngOnInit(); + + tick(100); + + // When + component.loadDataWithState(state); + + tick(600); + + // Then + const filter = componentModelStub.getComponentState().filter; + console.log(filter.criteria); + expect(filter.criteria).toEqual([ + { property: 'deployments.enabled', pattern: 'false', sort: null }, + { property: 'config.description', pattern: null, sort: DESC } + ]); + })); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.ts new file mode 100644 index 0000000000..8b72b21230 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/data-jobs-manage-grid.component.ts @@ -0,0 +1,216 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, ElementRef, Inject, OnInit } from '@angular/core'; +import { DOCUMENT, Location } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { VmwToastType } from '@vdk/shared'; + +import { ComponentService, ErrorHandlerService, NavigationService, RouterService, ToastService } from '@vdk/shared'; + +import { ErrorUtil } from '../../../../shared/utils'; + +import { ConfirmationModalOptions, ModalOptions } from '../../../../shared/model'; + +import { DATA_PIPELINES_CONFIGS, DataJobStatus, DataPipelinesConfig, ToastDefinitions } from '../../../../model'; +import { DataJobsApiService, DataJobsService } from '../../../../services'; + +import { ClrGridUIState, DataJobsBaseGridComponent } from '../../../base-grid/data-jobs-base-grid.component'; + +@Component({ + selector: 'lib-data-jobs-manage-grid', + templateUrl: './data-jobs-manage-grid.component.html', + styleUrls: ['./data-jobs-manage-grid.component.scss'] +}) +export class DataJobsManageGridComponent extends DataJobsBaseGridComponent implements OnInit { + readonly uuid = 'DataJobsManageGridComponent'; + + confirmStatusOptions: ModalOptions; + confirmExecuteNowOptions: ModalOptions; + + override clrGridDefaultFilter: ClrGridUIState['filter'] = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'deployments.enabled': DataJobStatus.ENABLED + }; + override clrGridDefaultSort: ClrGridUIState['sort'] = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'deployments.lastExecutionTime': -1 + }; + + override quickFiltersDefaultActiveIndex = 1; + + constructor( // NOSONAR + componentService: ComponentService, + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + routerService: RouterService, + dataJobsService: DataJobsService, + dataJobsApiService: DataJobsApiService, + errorHandlerService: ErrorHandlerService, + location: Location, + router: Router, + elementRef: ElementRef, + @Inject(DOCUMENT) document: Document, + @Inject(DATA_PIPELINES_CONFIGS) dataPipelinesModuleConfig: DataPipelinesConfig, + private readonly toastService: ToastService + ) { + super( + componentService, + navigationService, + activatedRoute, + routerService, + dataJobsService, + dataJobsApiService, + errorHandlerService, + location, + router, + elementRef, + document, + dataPipelinesModuleConfig, + 'manage_data_jobs_grid_user_config', + { + hiddenColumns: { + description: true, + nextRun: true, + lastDeployedDate: true, + lastDeployedBy: true, + notifications: true, + source: true + } + }); + + this.confirmStatusOptions = new ConfirmationModalOptions(); + this.confirmExecuteNowOptions = new ConfirmationModalOptions(); + + this._inputConfig(dataPipelinesModuleConfig); + } + + enable() { + this.confirmStatusOptions.message = `Job ${ this.selectedJob.jobName } will be enabled`; + this.confirmStatusOptions.infoText = `Enabling this job means that it will be scheduled for execution`; + this.confirmStatusOptions.opened = true; + } + + disable() { + this.confirmStatusOptions.message = `Job ${ this.selectedJob.jobName } will be disabled`; + this.confirmStatusOptions.infoText = `Disabling this job means that + it will NOT be scheduled for execution anymore`; + this.confirmStatusOptions.opened = true; + } + + onJobStatusChange() { + const selectedJobDeployment = this.extractSelectedJobDeployment(); + if (!selectedJobDeployment) { + console.log('Status update action will not be performed for job with no deployments.'); + return; + } + + this.subscriptions.push( + this.dataJobsApiService.updateDataJobStatus( + this.selectedJob.config?.team, + this.selectedJob.jobName, + selectedJobDeployment.id, + !selectedJobDeployment.enabled + ) + .subscribe({ + next: () => { + selectedJobDeployment.enabled = !selectedJobDeployment.enabled; + + const state = selectedJobDeployment.enabled + ? 'enabled' + : 'disabled'; + + this.toastService.show({ + type: VmwToastType.INFO, + title: `Status update completed`, + description: `Data job "${ this.selectedJob.jobName }" successfully ${ state }` + }); + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + title: `Updating status for Data job "${ this.selectedJob?.jobName }" failed` + } + ); + } + }) + ); + } + + executeDataJob() { + this.confirmExecuteNowOptions.message = `Job ${ this.selectedJob.jobName } will be queued for execution.`; + this.confirmExecuteNowOptions.infoText = `Confirming will result in immediate data job execution.`; + this.confirmExecuteNowOptions.opened = true; + } + + onExecuteDataJob() { + this.subscriptions.push( + this.dataJobsApiService.executeDataJob( + this.selectedJob.config?.team, + this.selectedJob.jobName, + this.extractSelectedJobDeployment().id + ) + .subscribe({ + next: () => { + this.toastService.show(ToastDefinitions.successfullyRanJob(this.selectedJob.jobName)); + }, + error: (error: unknown) => { + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error), + { + title: (error as HttpErrorResponse)?.status === 409 + ? 'Failed, Data job is already executing' + : 'Failed to queue Data job for execution' + } + ); + } + }) + ); + } + + resetTeamNameFilter() { + this.teamNameFilter = ''; + } + + showTeamsColumn() { + return (this.dataPipelinesModuleConfig?.manageConfig?.showTeamsColumn); + } + + extractSelectedJobDeployment() { + return this.selectedJob?.deployments[this.selectedJob?.deployments?.length - 1]; + } + + private _inputConfig(dataPipelinesModuleConfig: DataPipelinesConfig) { + if (dataPipelinesModuleConfig.manageConfig?.filterByTeamName) { + this.filterByTeamName = dataPipelinesModuleConfig.manageConfig?.filterByTeamName; + } + + if (dataPipelinesModuleConfig.manageConfig?.displayMode) { + this.displayMode = dataPipelinesModuleConfig.manageConfig?.displayMode; + } + + if (dataPipelinesModuleConfig.manageConfig?.selectedTeamNameObservable) { + this.subscriptions.push( + dataPipelinesModuleConfig.manageConfig + ?.selectedTeamNameObservable + .subscribe({ + next: (newTeam) => { + if (newTeam !== this.teamNameFilter) { + this.teamNameFilter = newTeam; + this.refresh(); + } + }, + error: (error: unknown) => { + this.resetTeamNameFilter(); + console.error('Error loading selected team', error); + } + }) + ); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/index.ts new file mode 100644 index 0000000000..c146efb67e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/components/grid/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-manage-grid.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.css b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.css new file mode 100644 index 0000000000..8ef7384c28 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.css @@ -0,0 +1,10 @@ +/* + * Copyright 2023-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.page-container { + display: grid; + height: 100%; + grid-template-rows: 40px 1fr; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.html new file mode 100644 index 0000000000..4767da5969 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.html @@ -0,0 +1,11 @@ + + +
+
+

Manage Data Jobs

+
+ +
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.spec.ts new file mode 100644 index 0000000000..cf085d51b6 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.spec.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DataJobsManagePageComponent } from './data-jobs-manage-page.component'; + +describe('DataJobsManageComponent', () => { + let component: DataJobsManagePageComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [DataJobsManagePageComponent] + }); + fixture = TestBed.createComponent(DataJobsManagePageComponent); + component = fixture.componentInstance; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.ts new file mode 100644 index 0000000000..d575eec96c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/data-jobs-manage-page.component.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib-data-jobs-manage', + templateUrl: './data-jobs-manage-page.component.html', + styleUrls: ['./data-jobs-manage-page.component.css'] +}) +export class DataJobsManagePageComponent { +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/index.ts new file mode 100644 index 0000000000..a70d99a351 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-manage-page.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/public-api.ts new file mode 100644 index 0000000000..338ef89baa --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/data-jobs-manage/public-api.ts @@ -0,0 +1,7 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './index'; +export * from './components/grid'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/public-api.ts new file mode 100644 index 0000000000..0a6cfcac0c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/public-api.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job/public-api'; +export * from './data-jobs-explore/public-api'; +export * from './data-jobs-manage/public-api'; +export * from './widgets/public-api'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.html new file mode 100644 index 0000000000..7c9e14460d --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.html @@ -0,0 +1,46 @@ + + + + + We couldn't find any failed executions! + + Most recent failed executions - Last 24h (UTC time) + + + Status + + + + + + End (UTC) + + + + + + + {{ jobExecution.id}} + + + + + + {{ jobExecution.endTime | date:'MMM d, y, hh:mm:ss a':'UTC' }} + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.scss new file mode 100644 index 0000000000..ad0933f325 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.scss @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.datagrid-executions-widget-table { + height: 300px; + + ::ng-deep .datagrid-table { + width: 100%; + + .header-title { + .datagrid-column-separator { + display: none; + } + } + + .datagrid-row-scrollable { + width: 100%; + } + + .job-name-column { + width: 60% !important; + } + + .status-column { + width: 20% !important; + padding-left: 0px !important; + white-space: nowrap; + } + + .time-column { + width: 20% !important; + min-width: 120px; + } + + clr-dg-cell { + .btn { + width: 100% !important; + text-align: left; + } + } + } +} + +clr-dg-cell .btn { + margin: 0 !important; + text-transform: none !important; + padding: 0.3rem 0.3rem 0.3rem; +} + +.no-padding { + padding-bottom: 0px; + padding-top: 0px; +} + +.hide { + display: none; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.ts new file mode 100644 index 0000000000..60af6c388a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/data-jobs-executions-widget.component.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; + +import { NavigationService } from '@vdk/shared'; + +import { DataJob, DataJobExecution, DataJobExecutions } from '../../../model'; + +@Component({ + selector: 'lib-data-jobs-executions-widget', + templateUrl: './data-jobs-executions-widget.component.html', + styleUrls: ['./data-jobs-executions-widget.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DataJobsExecutionsWidgetComponent implements OnChanges { + + @Input() manageLink: string; + @Input() allJobs: DataJob[]; + @Input() jobExecutions: DataJobExecutions; + + readonly uuid = 'DataJobsExecutionsWidgetComponent'; + + loading = true; + + constructor(private readonly navigationService: NavigationService) { + } + + /** + * ** NgFor elements tracking function. + */ + trackByFn(index: number, execution: DataJobExecution): string { + return `${ index }|${ execution?.id }`; + } + + /** + * @inheritDoc + */ + ngOnChanges(changes: SimpleChanges) { + if (changes['jobExecutions'] !== undefined && + changes['jobExecutions'].currentValue !== undefined) { + this.loading = false; + } + } + + navigateToJobExecutions(job?: DataJobExecution): void { + const dataJob = this.allJobs.find(el => el.jobName === job.jobName); + let link = this.manageLink; + link = link.replace('{team}', dataJob.config?.team); + link = link.replace('{data-job}', dataJob.jobName); + link = link + '/executions'; + + if (dataJob) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.navigationService.navigate(link); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/index.ts new file mode 100644 index 0000000000..39ac04c1a7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-executions-widget/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-executions-widget.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.html new file mode 100644 index 0000000000..fc5adbafbc --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.html @@ -0,0 +1,39 @@ + + + + + We couldn't find any failed jobs! + + Jobs with Failing executions - Last 24h + + + Failed executions + + + + + + + {{ dataJob.jobName}} + + + + + {{ dataJob.failedTotal}} + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.scss new file mode 100644 index 0000000000..a59c70cf61 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.scss @@ -0,0 +1,57 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.datagrid-failed-executions-widget { + height: 300px; + + ::ng-deep .datagrid-table { + width: 100%; + + .header-title { + .datagrid-column-separator { + display: none; + } + } + + .datagrid-row-scrollable { + width: 100%; + } + + .job-name-column { + width: 70% !important; + } + + .center { + width: 30% !important; + text-align: center + } + + clr-dg-cell { + .btn { + width: 100% !important; + text-align: left; + } + } + } +} + +clr-dg-cell .btn { + margin: 0 !important; + text-transform: none !important; + padding: 0.3rem 0.3rem 0.3rem; +} + +.no-padding { + padding-bottom: 0px; + padding-top: 0px; +} + +.custom-label { + width: 50px; +} + +.label-column { + display: none; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.ts new file mode 100644 index 0000000000..9fecc76f88 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/data-jobs-failed-widget.component.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; + +import { NavigationService } from '@vdk/shared'; + +import { DataJob, DataJobExecutions } from '../../../model'; + +interface DataJobGrid extends DataJob { + failedTotal?: number; +} + +@Component({ + selector: 'lib-data-jobs-failed-widget', + templateUrl: './data-jobs-failed-widget.component.html', + styleUrls: ['./data-jobs-failed-widget.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DataJobsFailedWidgetComponent implements OnChanges { + + @Input() manageLink: string; + @Input() allJobs: DataJob[]; + @Input() jobExecutions: DataJobExecutions = []; + + readonly uuid = 'DataJobsFailedWidgetComponent'; + + loading = true; + dataJobs: DataJobGrid[] = []; + + constructor(private readonly navigationService: NavigationService) { + } + + /** + * ** NgFor elements tracking function. + */ + trackByFn(index: number, dataJob: DataJob): string { + return `${ index }|${ dataJob?.jobName }`; + } + + /** + * @inheritDoc + */ + ngOnChanges(changes: SimpleChanges) { + if (changes['jobExecutions'] !== undefined) { + this.dataJobs = []; + (changes['jobExecutions'].currentValue as DataJobExecutions).forEach((element) => { + const temp = this.dataJobs.find(i => i.jobName === element.jobName); + if (!temp) { + this.dataJobs.push({ jobName: element.jobName, failedTotal: 1 } as DataJobGrid); + } else { + temp.failedTotal++; + } + }); + this.loading = false; + } + } + + navigateToJobDetails(job?: DataJob): void { + const dataJob = this.allJobs.find(el => el.jobName === job.jobName); + let link = this.manageLink; + link = link.replace('{team}', dataJob.config?.team); + link = link.replace('{data-job}', job.jobName); + link = link + '/details'; + + if (dataJob) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.navigationService.navigate(link); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/index.ts new file mode 100644 index 0000000000..a6962f98e1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-failed-widget/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-failed-widget.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.html new file mode 100644 index 0000000000..ea013b7969 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.html @@ -0,0 +1,53 @@ + + +
+
+ +
+
+

Data Jobs

+ +
+

Data Jobs help Data Engineers develop, deploy, run, and manage data processing workloads

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.scss new file mode 100644 index 0000000000..c081472067 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.scss @@ -0,0 +1,278 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@import '../variables.scss'; + +.widget { + position: relative; + height: 100%; + + .widget-section-container { + padding: 1.2rem; + height: 100%; + border-radius: .2rem; + border: none; + box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.1), 0 3px 5px 0 rgba(0, 0, 0, 0.09); + position: relative; + overflow: hidden; + ::ng-deep + .datagrid-table-wrapper { + max-width: 98.6% !important; + } + } +} + +::ng-deep +.fade-to-dark.dark { + .widget-card { + --label-color-error: #F27963; + --label-color-success: #5EB715; + --header-background: #28404D; + --widget-border: 1px solid #28404D; + --widget-clickable-background: #324F62; + --widget-details-background: #21333B; + --widget-details-hover-background: #1B2A32; + --widget-details-color: #B3B3B3; + --widget-details-border: 2px solid #28404D; + --widget-icon-backgound-color: #194B70; + } +} + +.widget-card { + --label-color-error: #F35E44; + --label-color-success: #5AA220; + --header-background: #{$color-white}; + --widget-border: 1px solid #E3F5FC; + --widget-clickable-background: #E8E8E8; + --widget-details-background: #{$color-white}; + --widget-details-hover-background: #D8E3E9; + --widget-details-color: #8C8C8C; + --widget-details-border: 2px solid #E3F5FC; + --widget-icon-backgound-color: #0072A3; + + .label-error { + color: var(--label-color-error); + } + + .label-success { + color: var(--label-color-success); + } + + .widget-footer { + position: relative; + min-height: 25px; + margin: 1.3rem -1.2rem -1.2rem; + border-top: var(--widget-border); + + a { + margin: 0 0 0 0.5rem; + padding: 0px; + } + } + + .widget-header { + background: var(--header-background); + margin: -1.2rem -1.2rem 0 -1.2rem; + padding: .8rem; + border-radius: .1rem .1rem 0 0; + position: relative; + + .widget-header-title-refresh-button { + display: flex; + align-items: center; + + .header-title { + margin-top: 0; + font-weight: 200; + + clr-icon { + margin-top: -2px; + } + } + } + p { + margin-top: .4rem; + line-height: .8rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + .widget-container { + z-index: 100; + + .widget-values { + line-height: 0.8rem; + display: grid; + grid-template: ". . ." 1fr / 1fr 1fr 1fr; + margin-left: -1.2rem; + margin-right: -1.2rem; + margin-bottom: -1.2rem; + row-gap: 1.2rem; + border-top: var(--widget-border); + + .widget-value { + display: block; + text-align: center; + background: var(--widget-details-background); + + &:not(:first-child) { + border-left: var(--widget-border); + } + + &:not(:last-child) { + border-right: var(--widget-border); + } + + padding: .6rem; + + .widget-title { + margin-bottom: .2rem; + margin-top: .2rem; + font-size: 1.2rem; + font-weight: 500; + display: block; + } + + .widget-text { + font-size: .55rem; + display: block; + line-height: .6rem; + } + + &.widget-clickable { + cursor: pointer; + + &:hover { + background: var(--widget-clickable-background); + } + } + + &.show-details { + background: var(--widget-clickable-background); + + .widget-text { + font-weight: bold; + } + + position: relative; + } + } + } + } + + .widget-details { + z-index: 1; + height: 0; + margin: 1.2rem -1.2rem -1.2rem -1.2rem; + transition: height .3s; + overflow: hidden; + background: var(--widget-details-background); + + &.show-details { + height: 240px; + border-top: var(--widget-details-border); + } + + .no-issues { + height: 100%; + width: 100%; + text-align: center; + margin-top: 1.2rem; + + .no-issues-img { + display: block; + height: 120px; + margin: 0 auto; + } + + .no-issues-text { + color: var(--widget-details-color); + } + } + + .data-details { + height: 240px; + position: relative; + + :host + ::ng-deep + clr-datagrid { + height: 100%; + + .datagrid { + margin-top: 0; + flex-basis: 0; + border: 0; + + .datagrid-row { + border-top: none; + border-bottom: none; + } + + .datagrid-placeholder-container { + border-top: none; + } + } + + .datagrid-footer { + padding: 0.1rem 0.5rem; + border-right: none; + border-left: none; + border-bottom: none; + } + } + + .data-row { + display: block; + margin-top: 0; + position: relative; + padding-left: .2rem; + + &.clickable { + cursor: pointer; + } + + .data-title { + line-height: .6rem; + font-weight: 500; + display: block; + margin-top: 0; + position: relative; + + .btn { + margin: 0; + } + + .title-icon { + float: left; + width: 32px; + display: flex; + justify-content: center; + + lib-status-cell { + width: 15px; + display: flex; + justify-content: center; + } + } + } + + .data-description { + display: block; + margin-top: 0; + color: var(--widget-details-color); + line-height: 1rem; + + clr-icon { + margin-top: -2px; + } + } + } + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.ts new file mode 100644 index 0000000000..527f7f8a2a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/data-jobs-health-panel.component.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Inject, Input, Output } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { + ApiPredicate, + CollectionsUtil, + ComponentModel, + ComponentService, + DESC, + ErrorHandlerService, + NavigationService, + OnTaurusModelChange, + OnTaurusModelError, + OnTaurusModelInit, + OnTaurusModelLoad, + RouterService, + TaurusBaseComponent +} from '@vdk/shared'; + +import { ErrorUtil } from '../../../shared/utils'; + +import { DataJobsService } from '../../../services'; +import { + DATA_PIPELINES_CONFIGS, + DataJob, + DataJobExecutionFilter, + DataJobExecutionOrder, + DataJobExecutions, + DataJobExecutionStatus, + DataJobPage, + DataPipelinesConfig, + FILTER_REQ_PARAM, + JOB_EXECUTIONS_DATA_KEY, + JOB_NAME_REQ_PARAM, + JOBS_DATA_KEY, + ORDER_REQ_PARAM, + TEAM_NAME_REQ_PARAM +} from '../../../model'; +import { TASK_LOAD_JOB_EXECUTIONS } from '../../../state/tasks'; + +import { DataJobExecutionToGridDataJobExecution } from '../../data-job/pages/executions'; + +enum State { + loading = 'loading', + ready = 'ready', + empty = 'empty' +} + +@Component({ + selector: 'lib-data-jobs-health-panel', + templateUrl: './data-jobs-health-panel.component.html', + styleUrls: ['./data-jobs-health-panel.component.scss'] +}) +export class DataJobsHealthPanelComponent extends TaurusBaseComponent + implements OnTaurusModelInit, OnTaurusModelLoad, OnTaurusModelChange, OnTaurusModelError { + + @Input() manageLink: string; + @Output() componentStateEvent = new EventEmitter(); + + readonly uuid = 'DataJobsHealthPanelComponent'; + + loadingJobs = true; + loadingExecutions = true; + + teamName: string; + loading = true; + dataJobs: DataJob[]; + jobExecutions: DataJobExecutions; + + constructor( + componentService: ComponentService, + navigationService: NavigationService, + activatedRoute: ActivatedRoute, + private readonly routerService: RouterService, + private readonly dataJobsService: DataJobsService, + private readonly errorHandlerService: ErrorHandlerService, + @Inject(DATA_PIPELINES_CONFIGS) public readonly dataPipelinesModuleConfig: DataPipelinesConfig) { + super(componentService, navigationService, activatedRoute); + } + + fetchDataJobs(): void { + this.loadingJobs = true; + const filters: ApiPredicate[] = []; + + if (this.teamName) { + filters.push({ + property: 'config.team', + pattern: this.teamName, + sort: null + }); + } + + this.dataJobsService.loadJobs( + this.model + .withRequestParam(TEAM_NAME_REQ_PARAM, 'no-team-specified') + .withRequestParam(JOB_NAME_REQ_PARAM, '') + .withFilter(filters) + .withRequestParam(ORDER_REQ_PARAM, { property: 'startTime', direction: DESC }) + .withPage(1, 1000) + ); + } + + fetchDataJobExecutions(): void { + this.loadingExecutions = true; + const d = new Date(); + d.setDate(d.getDate() - 1); + this.dataJobsService.loadJobExecutions( + this.model + .withRequestParam(TEAM_NAME_REQ_PARAM, 'no-team-specified') + .withRequestParam(JOB_NAME_REQ_PARAM, '') + .withRequestParam( + FILTER_REQ_PARAM, + { + statusIn: [DataJobExecutionStatus.USER_ERROR, DataJobExecutionStatus.PLATFORM_ERROR], + startTimeGte: d, + teamNameIn: this.teamName ? [this.teamName] : [] + } as DataJobExecutionFilter + ) + .withRequestParam(ORDER_REQ_PARAM, { property: 'startTime', direction: DESC } as DataJobExecutionOrder) + ); + } + + /** + * @inheritDoc + */ + onModelInit(): void { + this._subscribeForTeamChange(); + this.emitNewState(); + } + + /** + * @inheritDoc + */ + onModelLoad(): void { + this.loading = false; + } + + /** + * @inheritDoc + */ + onModelChange(model: ComponentModel, task: string): void { + if (task === TASK_LOAD_JOB_EXECUTIONS) { + const executions: DataJobExecutions = model.getComponentState().data.get(JOB_EXECUTIONS_DATA_KEY); + if (executions) { + const remappedExecutions = DataJobExecutionToGridDataJobExecution.convertToDataJobExecution([...executions]); + this.jobExecutions = remappedExecutions.filter((ex) => ex.status !== DataJobExecutionStatus.SUCCEEDED); + this.loadingExecutions = false; + } + } else { + const componentState = model.getComponentState(); + const dataJobsData: DataJobPage = componentState.data.get(JOBS_DATA_KEY); + + this.dataJobs = CollectionsUtil.isArray(dataJobsData?.content) + ? [...dataJobsData?.content] + : []; + this.loadingJobs = false; + } + this.emitNewState(); + } + + /** + * @inheritDoc + */ + onModelError(model: ComponentModel, task: string): void { + if (task === TASK_LOAD_JOB_EXECUTIONS) { + this.jobExecutions = []; + this.loadingExecutions = false; + } else { + this.loadingJobs = false; + } + + const error = ErrorUtil.extractError( + model.getComponentState().error + ); + + this.errorHandlerService.processError(error); + } + + private emitNewState() { + if (this.loadingJobs || this.loadingExecutions) { + this.componentStateEvent.emit(State.loading); + } else if (this.jobExecutions.length === 0 && this.dataJobs.length === 0) { + this.componentStateEvent.emit(State.empty); + } else { + this.componentStateEvent.emit(State.ready); + } + } + + private _subscribeForTeamChange(): void { + if (this.dataPipelinesModuleConfig.manageConfig?.selectedTeamNameObservable) { + this.subscriptions.push( + this.dataPipelinesModuleConfig.manageConfig?.selectedTeamNameObservable + .subscribe({ + next: (newTeamName: string) => { + if (newTeamName !== this.teamName) { + if (newTeamName && newTeamName !== '') { + this.teamName = newTeamName; + this.fetchDataJobExecutions(); + this.fetchDataJobs(); + } + } + }, + error: (error: unknown) => { + this.jobExecutions = []; + this.dataJobs = []; + console.error('Error loading selected team', error); + } + }) + ); + } else { + this.fetchDataJobExecutions(); + this.fetchDataJobs(); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/index.ts new file mode 100644 index 0000000000..3ced5012b1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-health-panel/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-health-panel.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.html new file mode 100644 index 0000000000..ee62e1fa99 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.html @@ -0,0 +1,150 @@ + + +
+
+ +
+

Data Jobs

+

Data Jobs help Data Engineers develop, deploy, run, and manage data processing workloads

+
+ +
+
+
+
+ + + + Data Jobs + +
+
+
+ + + + Job Executions + last 24 hours +
+
+ + + + Failures + last 24 hours +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+ + +
+ + + +
+ {{item.jobName}} + {{ item.startTime | date: 'MMM d, y, hh:mm a' }} + , took 12min, {{item.status}} +
+
+
+ Coming Soon! + + + + +
+
+
+ + +
+ + + +
+ {{item.jobName}} + {{ item.startTime | date: 'MMM d, y, hh:mm a' }} + , took 12min, {{item.status}} +
+
+
+ Coming Soon! + + + + + +
+
+
+ + +
+ + Job + Schedule (in UTC) + + +
+ {{item.jobName}} +
+
+ {{item.config?.schedule?.scheduleCron | formatSchedule: 'Not scheduled'}} + +
+ We couldn't find any data jobs, but you can always create one! + + + + + +
+
+
+ + +
+
+ Loading ... +
+
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.scss new file mode 100644 index 0000000000..89aa8fbb4b --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.scss @@ -0,0 +1,265 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@import 'variables.scss'; + +::ng-deep +.fade-to-dark.dark { + .widget-card { + --label-color-error: #F27963; + --label-color-success: #5EB715; + --header-background: #28404D; + --widget-border: 1px solid #28404D; + --widget-clickable-background: #324F62; + --widget-details-background: #21333B; + --widget-details-hover-background: #1B2A32; + --widget-details-color: #B3B3B3; + --widget-details-border: 2px solid #28404D; + --widget-icon-backgound-color: #194B70; + } +} + +.widget-card { + --label-color-error: #F35E44; + --label-color-success: #5AA220; + --header-background: #{$color-white}; + --widget-border: 1px solid #E3F5FC; + --widget-clickable-background: #E8E8E8; + --widget-details-background: #{$color-white}; + --widget-details-hover-background: #D8E3E9; + --widget-details-color: #8C8C8C; + --widget-details-border: 2px solid #E3F5FC; + --widget-icon-backgound-color: #0072A3; + + .label-error { + color: var(--label-color-error); + } + + .label-success { + color: var(--label-color-success); + } + + .widget-footer { + position: relative; + min-height: 25px; + margin: 1.3rem -1.2rem -1.2rem; + border-top: var(--widget-border); + + a { + margin: 0 0 0 0.5rem; + padding: 0px; + } + } + + .widget-header { + background: var(--header-background); + margin: -1.2rem -1.2rem 0 -1.2rem; + padding: .8rem; + border-radius: .1rem .1rem 0 0; + position: relative; + min-height: 96px; + + .header-title { + margin-top: 0; + font-weight: 200; + + clr-icon { + margin-top: -2px; + } + } + + p { + margin-top: .3rem; + line-height: .8rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .header-link { + position: absolute; + top: .4rem; + right: .3rem; + + clr-icon { + margin-top: -.1rem; + } + } + } + + .widget-container { + z-index: 100; + + .widget-values { + line-height: 0.8rem; + display: grid; + grid-template: ". . ." 1fr / 1fr 1fr 1fr; + margin-left: -1.2rem; + margin-right: -1.2rem; + margin-bottom: -1.2rem; + row-gap: 1.2rem; + border-top: var(--widget-border); + + .widget-value { + display: block; + text-align: center; + background: var(--widget-details-background); + + &:not(:first-child) { + border-left: var(--widget-border); + } + + &:not(:last-child) { + border-right: var(--widget-border); + } + + padding: .6rem; + + .widget-title { + margin-bottom: .2rem; + margin-top: .2rem; + font-size: 1.2rem; + font-weight: 500; + display: block; + } + + .widget-text { + font-size: .55rem; + display: block; + line-height: .6rem; + } + + &.widget-clickable { + cursor: pointer; + + &:hover { + background: var(--widget-clickable-background); + } + } + + &.show-details { + background: var(--widget-clickable-background); + + .widget-text { + font-weight: bold; + } + + position: relative; + } + } + } + } + + .widget-details { + z-index: 1; + height: 0; + margin: 1.2rem -1.2rem -1.2rem -1.2rem; + transition: height .3s; + overflow: hidden; + background: var(--widget-details-background); + + &.show-details { + height: 240px; + border-top: var(--widget-details-border); + } + + .no-issues { + height: 100%; + width: 100%; + text-align: center; + margin-top: 1.2rem; + + .no-issues-img { + display: block; + height: 120px; + margin: 0 auto; + } + + .no-issues-text { + color: var(--widget-details-color); + } + } + + .data-details { + height: 240px; + position: relative; + + ::ng-deep + clr-datagrid { + height: 100%; + + .datagrid { + margin-top: 0; + flex-basis: 0; + border: 0; + + .datagrid-row { + border-top: none; + border-bottom: none; + } + + .datagrid-placeholder-container { + border-top: none; + } + } + + .datagrid-footer { + padding: 0.1rem 0.5rem; + border-right: none; + border-left: none; + border-bottom: none; + } + } + + .data-row { + display: block; + margin-top: 0; + position: relative; + padding-left: .2rem; + + &.clickable { + cursor: pointer; + } + + .data-title { + line-height: .6rem; + font-weight: 500; + display: block; + margin-top: 0; + position: relative; + + .btn { + margin: 0; + } + + .title-icon { + float: left; + width: 32px; + display: flex; + justify-content: center; + + lib-status-cell { + width: 15px; + display: flex; + justify-content: center; + } + } + } + + .data-description { + display: block; + margin-top: 0; + color: var(--widget-details-color); + line-height: 1rem; + + clr-icon { + margin-top: -2px; + } + } + } + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.spec.ts new file mode 100644 index 0000000000..84d854e2db --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.spec.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; + +import { ErrorHandlerService } from '@vdk/shared'; + +import { FormatSchedulePipe } from '../../shared/pipes'; + +import { DataJobsApiService } from '../../services'; + +import { DataJobsWidgetOneComponent, WidgetTab } from './data-jobs-widget-one.component'; + +describe('DataJobsWidgetOneComponent', () => { + let errorHandlerServiceStub: jasmine.SpyObj; + + let component: DataJobsWidgetOneComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + // mock service + const dataJobsServiceStub = () => ({ + getJobs: () => of({ + data: { + totalItems: 1, totalPages: 11, content: [ + { + jobName: 'test-job', + item: { config: { schedule: { scheduleCron: '*/5 * * * *' } } } + } + ] + } + }) + }); + errorHandlerServiceStub = jasmine.createSpyObj('errorHandlerService', [ + 'processError', + 'handleError' + ]); + + await TestBed.configureTestingModule({ + declarations: [DataJobsWidgetOneComponent, FormatSchedulePipe], + providers: [ + { provide: DataJobsApiService, useFactory: dataJobsServiceStub }, + { provide: ErrorHandlerService, useValue: errorHandlerServiceStub } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DataJobsWidgetOneComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('make expected calls', () => { + const dataJobsServiceStub: DataJobsApiService = fixture.debugElement.injector.get( + DataJobsApiService + ); + spyOn(dataJobsServiceStub, 'getJobs').and.callThrough(); + component.ngOnInit(); + expect(dataJobsServiceStub.getJobs).toHaveBeenCalled(); + + // TODO: make expected calls to executions & failures + }); + }); + + describe('switchTab', () => { + it('switch to expected tab', () => { + component.switchTab(WidgetTab.EXECUTIONS); + expect(component.selectedTab).toEqual(WidgetTab.EXECUTIONS); + expect(component.currentPage).toEqual(1); + }); + + it('collapse panel when click the same selected tab', () => { + component.switchTab(WidgetTab.EXECUTIONS); + component.switchTab(WidgetTab.EXECUTIONS); + expect(component.selectedTab).toEqual(WidgetTab.NONE); + }); + }); + + describe('handle errors', () => { + it('should catch error when API fails', () => { + expect(component.errorJobs).toEqual(false); + const dataJobsServiceStub: DataJobsApiService = fixture.debugElement.injector.get( + DataJobsApiService + ); + spyOn(dataJobsServiceStub, 'getJobs').and.returnValue(throwError(() => true)); + component.refresh(1, WidgetTab.DATAJOBS); + + component.jobs$.subscribe(); + component.executions$.subscribe(); + component.failures$.subscribe(); + + expect(component.errorJobs).toEqual(true); + + // TODO: handle errors for executions & failures + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.ts new file mode 100644 index 0000000000..dc5dbd4d75 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/data-jobs-widget-one.component.ts @@ -0,0 +1,180 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, HostListener, Input, OnInit } from '@angular/core'; + +import { Observable, of, throwError } from 'rxjs'; +import { catchError, delay, map } from 'rxjs/operators'; + +import { ErrorHandlerService } from '@vdk/shared'; + +import { ErrorUtil } from '../../shared/utils'; + +import { DataJobExecutionStatus, DataJobPage } from '../../model'; + +import { DataJobsApiService } from '../../services'; + +export enum WidgetTab { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + DATAJOBS, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + EXECUTIONS, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + FAILURES, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + NONE +} + +// TODO: Remove when consume data from API +const executionsMock = [ + { + jobName: 'data-job-1', + status: DataJobExecutionStatus.FINISHED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'auserov' + }, + { + jobName: 'data-job-2', + status: DataJobExecutionStatus.FAILED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'buserov' + }, + { + jobName: 'data-job-3', + status: DataJobExecutionStatus.FINISHED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'cuserov' + }, + { + jobName: 'data-job-long-name-test-1', + status: DataJobExecutionStatus.FINISHED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'duserov' + }, + { + jobName: 'data-job-long-name-test-2', + status: DataJobExecutionStatus.FAILED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'euserov' + }, + { + jobName: 'data-job-long-name-test-3', + status: DataJobExecutionStatus.SUBMITTED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'fuserov' + }, + { + jobName: 'data-job-long-name-test-4', + status: DataJobExecutionStatus.PLATFORM_ERROR, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'guserov' + }, + { + jobName: 'data-job-long-name-test-5', + status: DataJobExecutionStatus.USER_ERROR, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'huserov' + }, + { + jobName: 'data-job-a-very-long-name-listed-here-test-1', + status: DataJobExecutionStatus.FINISHED, + startTime: Date.now(), + endTime: Date.now(), + startedBy: 'fuserov' + } +]; + +@Component({ + selector: 'lib-data-jobs-widget-one', + templateUrl: './data-jobs-widget-one.component.html', + styleUrls: ['./data-jobs-widget-one.component.scss', './widget.scss'] +}) +export class DataJobsWidgetOneComponent implements OnInit { + @Input() manageLink: string; + + selectedTab: WidgetTab = WidgetTab.DATAJOBS; + jobs$: Observable; + /* eslint-disable @typescript-eslint/no-explicit-any */ + executions$: Observable; + failures$: Observable; + /* eslint-enable @typescript-eslint/no-explicit-any */ + widgetTab = WidgetTab; + pageSize = 25; + currentPage = 1; + + errorJobs: boolean; + + constructor(private readonly dataJobsService: DataJobsApiService, + private readonly errorHandlerService: ErrorHandlerService) { + } + + @HostListener('window:resize') + onWindowResize() { + // Listener was needed because ChangeDetection cycle doesn't run for Component on "window resize event" + // and doesn't update the data grid column width as expected, + // so only solution was to add dummy listener for window:resize which triggers ChangeDetection cycle inside the Component. + // No-op! Updates the component when the window resizes. This is used for resizing the data grid columns. + } + + ngOnInit() { + this.refreshAll(); + } + + refreshAll() { + this.refresh(this.currentPage, WidgetTab.DATAJOBS); + this.refresh(this.currentPage, WidgetTab.EXECUTIONS); + this.refresh(this.currentPage, WidgetTab.FAILURES); + } + + switchTab(tab: WidgetTab) { + this.selectedTab = this.selectedTab !== tab ? tab : WidgetTab.NONE; + this.currentPage = 1; + } + + refresh(currentPage: number, tab: WidgetTab) { + this.currentPage = currentPage; + + switch (tab) { + case WidgetTab.DATAJOBS: + this.errorJobs = false; + this.jobs$ = this.dataJobsService.getJobs([], '', this.currentPage, this.pageSize).pipe( + map(result => result?.data), + catchError((error: unknown) => { + this.errorJobs = !!error; + + this.errorHandlerService.processError( + ErrorUtil.extractError(error as Error) + ); + + return throwError(() => error); + }) + ); + break; + case WidgetTab.EXECUTIONS: + // TODO: Consume data from API + this.executions$ = of({ data: { totalItems: 0, totalPages: executionsMock.length / this.pageSize, content: [] } }).pipe( + map(result => result?.data), + delay(1200) // TODO: Remove delay when consume data from API + ); + break; + case WidgetTab.FAILURES: + // TODO: Consume data from API + const failuresMock = executionsMock.filter(e => e.status === DataJobExecutionStatus.FAILED); + this.failures$ = of({ data: { totalItems: 0, totalPages: failuresMock.length / this.pageSize, content: [] } }).pipe( + map(result => result?.data), + delay(1800) // TODO: Remove delay when consume data from API + ); + break; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/index.ts new file mode 100644 index 0000000000..b3d41fcd24 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-widget-one.component'; +export * from './data-jobs-executions-widget'; +export * from './data-jobs-failed-widget'; +export * from './data-jobs-health-panel'; +export * from './widget-execution-status-gauge'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/public-api.ts new file mode 100644 index 0000000000..484b60ff3c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/public-api.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './index'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/variables.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/variables.scss new file mode 100644 index 0000000000..200f08593f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/variables.scss @@ -0,0 +1,12 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// light +$color-white: #FFFFFF; +$background-primary-color: #0072A3; +$widget-color: #666666; + +// dark +$background-primary-color-dark: #004B6B; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/index.ts new file mode 100644 index 0000000000..ec1516fe11 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './widget-execution-status-gauge.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.html new file mode 100644 index 0000000000..e57ce9314c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.html @@ -0,0 +1,54 @@ + + +
+
+
+ +
Job Executions
+
+
Success Rate
+
+ + +

+ Success rate is calculated from all executions from the last 14 days (up to 336 for each job) +

+
+
+
+
+
{{successRate | percent}}
+
{{failedExecutions}} failed +
+
{{totalExecutions}} total +
+
+
+
+ + +
+
+
+
+ + +
+ +
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.scss new file mode 100644 index 0000000000..ba74d848f7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.scss @@ -0,0 +1,595 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +$xxs-width: 321px; + +$xs-width: 543px; +$xs-max: 543px; + +$small-min: 544px; +$small-width: 767px; +$small-max: 767px; + +$medium-min: 768px; +$medium-width: 991px; +$medium-width2: 990px; + +$large-min: 992px; +$large-width: 1199px; + +$xl-min-width: 1600px; +$xxl-min-width: 1919px; + +$min-el-size: 310px; + +.medium-and-up { + @media screen and (max-width: $medium-width2) { + display: none !important; + } +} + +.small-and-up { + @media screen and (max-width: $xs-max) { + display: none !important; + } +} + +.medium-and-down { + @media screen and (min-width: $medium-width) { + display: none !important; + } +} + +.small-and-down { + @media screen and (min-width: $small-width) { + display: none !important; + } +} + +$gray1: #747474; +$gray2: #eee; +$gray3: #ddd; +$gray4: #ccc; +$gray5: #8C8C8C; +$black: #000; +$black1: #313131; +$error-red: #e62700; +$error-red-dark-theme: #f54f47; +$dark-text: #adbbc4; +$red2: #c92100; +$ready-green: #318700; +$darkblue: rgb(0, 106, 144); +$lighterBlue: #89CBDF; +$lightblue: #49AFD9; +$darkButtonHoverBlue: #0095D3; +$blue3: #49afd9; +$blue4: #57c7ea; +$blue5: #0079b8; +$inactiveGray: #95A4B2; +$bgcolor: #25333D; +$bgcolorDark: #0f171c; +$bgcolorTextareaDark: #17242b; +$white: #FFF; +$focused-control-border: #6bc1e3; +$dark-theme-text: #acbac3; +$label-danger-color: #a32100; +$label-success-color: #266900; +$label-info-color: #004c8a; +$clr-primary-color: #0072a3; +$clr-primary-color-dark: #4aaed9; +$clr-input-border-grey: #b3b3b3; + + +$chart-margin: 20px; +$xl-width: 256px; +$lg-width: 230px; +$med-width: 188px; +$sm-width: 164px; +$xs-width: 130px; + +$red: rgb(194, 84, 0); + +::ng-deep .dark lib-widget-execution-status-gauge { + .gauge-container { + margin-top: 50px; + .gauge-meta .value-percent { + //color: $lightblue !important; + } + + svg.ngx-charts g[ngx-charts-pie-arc]:not(.background-arc) > g.arc-group > path.arc { + //fill: $lightblue !important; + } + + svg.ngx-charts g[ngx-charts-pie-arc].background-arc > g.arc-group > path.arc { + fill: rgba(0, 0, 0, 0.25); + } + } +} + +::ng-deep .dark vmw-gauge { + .gauge-container.above-threshold { + .gauge-meta .value-percent { + color: $red !important; + } + + svg.ngx-charts g[ngx-charts-pie-arc]:not(.background-arc) > g.arc-group > path.arc { + fill: $red !important; + } + + svg.ngx-charts g[ngx-charts-pie-arc].background-arc > g.arc-group > path.arc { + fill: rgba(0, 0, 0, 0.25); + } + } + +} + +::ng-deep lib-widget-execution-status-gauge { + svg.ngx-charts { + margin: -$chart-margin; + + g text { + display: none; + } + + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.10); + opacity: 0.3; + + g.background-arc { + display:none; + } + } + } + } + + .above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.05); + } + } + } +} + +.gauge-container { + padding: 0; + height: $xl-width - 2 * $chart-margin; + width: $xl-width - 2 * $chart-margin; + position: relative; + margin-top: 50px; + margin-left: auto; + margin-right: auto; + .gauge-chart-container { + width: inherit; + height: inherit; + overflow: hidden; + } + + .gauge-chart { + height: $xl-width; + width: $xl-width; + margin: 0 auto; + } + + &.above-threshold { + .gauge-meta .value-percent { + color: $red; + } + } + + .gauge-meta { + text-align: center; + position: absolute; + width: inherit; + z-index: 1; + + // integrators can use whatever tag they like for the title, e.g. + // h4 if they want screen readers to notice the gauge as a title, + // or span if they do not. + ::ng-deep .gauge-title { + display: block; + font-size: 19px; + margin-top: 40px; + } + + .value-percent { + color: $darkblue; + margin-top: 15px; + margin-bottom: 23px; + font-size: 42.5px; + } + + .value-current { + font-size: 16px; + } + + .value-limit { + font-size: 14px; + color: #9a9a9a; + margin-top: 0px; + } + } +} + +.large { + &.gauge-container { + height: $lg-width - 2 * $chart-margin + 10px; + width: $lg-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $lg-width; + width: $lg-width; + } + + .gauge-meta { + .value-limit { + margin-top: 0; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.07) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.12); + } + } + } +} + +@media (min-width: $large-min) and (max-width: $large-width) { + .auto { + &.gauge-container { + height: $lg-width - 2 * $chart-margin + 10px; + width: $lg-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $lg-width; + width: $lg-width; + } + + .gauge-meta { + .value-limit { + margin-top: 0; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.07) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.12); + } + } + } + } +} + +.medium { + &.gauge-container { + height: $med-width - 2 * $chart-margin + 10px; + width: $med-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $med-width; + width: $med-width; + } + + .gauge-meta { + ::ng-deep .gauge-title { + font-size: 20px; + margin-top: 28px; + } + + .value-percent { + margin: 10px 0; + font-size: 26px; + } + + .value-current { + font-size: 16px; + } + + .value-limit { + font-size: 13px; + margin-top: 0; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.09) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.16); + } + } + } +} + +@media (min-width: $medium-min) and (max-width: $medium-width) { + .auto { + &.gauge-container { + height: $med-width - 2 * $chart-margin + 10px; + width: $med-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $med-width; + width: $med-width; + } + + .gauge-meta { + ::ng-deep .gauge-title { + font-size: 20px; + margin-top: 28px; + } + + .value-percent { + margin: 10px 0; + font-size: 26px; + } + + .value-current { + font-size: 16px; + } + + .value-limit { + font-size: 13px; + margin-top: 0; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.09) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.16); + } + } + } + } +} + +.small { + &.gauge-container { + height: $sm-width - 2 * $chart-margin + 10px; + width: $sm-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $sm-width; + width: $sm-width; + } + + .gauge-meta { + ::ng-deep .gauge-title { + font-size: 17px; + margin-top: 26px; + } + + .value-percent { + margin: 0; + font-size: 18.5px; + } + + .value-current { + font-size: 14px; + } + + .value-limit { + font-size: 10px; + margin-top: 0; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.10) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.20); + } + } + } +} + +@media (min-width: $small-min) and (max-width: $small-max) { + .auto { + &.gauge-container { + height: $sm-width - 2 * $chart-margin + 10px; + width: $sm-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $sm-width; + width: $sm-width; + } + + .gauge-meta { + ::ng-deep .gauge-title { + font-size: 17px; + margin-top: 26px; + } + + .value-percent { + margin: 0; + font-size: 18.5px; + } + + .value-current { + font-size: 14px; + } + + .value-limit { + font-size: 10px; + margin-top: 0; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.10) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.20); + } + } + } + } +} + +.xs { + &.gauge-container { + height: $xs-width - 2 * $chart-margin + 10px; + width: $xs-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $xs-width; + width: $xs-width; + } + + .gauge-meta { + .value-current, + .value-limit { + display: none; + } + + ::ng-deep .gauge-title { + width: inherit; + font-size: 14px; + margin-top: 14px; + } + + .value-percent { + margin-top: 0px; + font-size: 18.5px; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.15) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.31); + } + } + } +} + +@media (max-width: $xs-max) { + .auto { + &.gauge-container { + height: $xs-width - 2 * $chart-margin + 10px; + width: $xs-width - 2 * $chart-margin; + } + + .gauge-chart { + height: $xs-width; + width: $xs-width; + } + + .gauge-meta { + .value-current, + .value-limit { + display: none; + } + + ::ng-deep .gauge-title { + width: 100%; + font-size: 14px; + margin-top: 14px; + } + + .value-percent { + margin-top: 0px; + font-size: 18.5px; + } + } + + ::ng-deep &.above-threshold svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.15) !important; + } + } + } + + ::ng-deep svg.ngx-charts { + g.gauge { + > g:nth-child(2) { + transform: rotate(225deg) scale(1.31); + } + } + } + } +} + +.centered { + text-align: center; + margin-top: 100px +} + +.success-rate-container { + display: flex; + justify-content: center; + align-items: center; + + .success-rate-info-panel { + font-size: small; + margin-top: 0px; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.ts new file mode 100644 index 0000000000..38d7ccb69c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget-execution-status-gauge/widget-execution-status-gauge.component.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; + +import { DataJob } from '../../../model'; + +@Component({ + selector: 'lib-widget-execution-status-gauge', + templateUrl: './widget-execution-status-gauge.component.html', + styleUrls: ['./widget-execution-status-gauge.component.scss'] +}) +export class WidgetExecutionStatusGaugeComponent implements OnChanges { + + @Input() allJobs: DataJob[]; + failedExecutions: number; + successfulExecutions: number; + totalExecutions: number; + successRate: number; + loading = true; + + ngOnChanges(changes: SimpleChanges) { + if (changes['allJobs'].currentValue) { + this.failedExecutions = 0; + this.successfulExecutions = 0; + (changes['allJobs'].currentValue as DataJob[]).forEach( + (dataJob) => { + if (dataJob.deployments) { + this.failedExecutions += dataJob.deployments[0].failedExecutions; + this.successfulExecutions += dataJob.deployments[0].successfulExecutions; + } + } + ); + this.totalExecutions = this.failedExecutions + this.successfulExecutions; + this.successRate = this.successfulExecutions / this.totalExecutions; + this.loading = false; + } + } + + customColors(name) { + if (name >= 95) { + return '#5AA220'; + } else if (name >= 90) { + return '#EFC006'; + } else { + return '#F35E44'; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget.scss new file mode 100644 index 0000000000..eaaaef19be --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/components/widgets/widget.scss @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +@import "variables.scss"; + +.widget { + position: relative; + height: 100%; + + .widget-section-container { + padding: 1.2rem; + height: 100%; + border-radius: .2rem; + border: none; + box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.1), 0 3px 5px 0 rgba(0, 0, 0, 0.09); + position: relative; + overflow: hidden; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/data-pipelines.module.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/data-pipelines.module.ts new file mode 100644 index 0000000000..14b34de9a5 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/data-pipelines.module.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { TruncateModule } from '@yellowspot/ng-truncate'; + +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { TimeagoModule } from 'ngx-timeago'; +import { LottieModule } from 'ngx-lottie'; + +import { DpDatePickerModule } from 'ng2-date-picker'; + +import { ClarityModule, ClrDatagridModule, ClrSpinnerModule } from '@clr/angular'; + +import { VmwComponentsModule } from '@vdk/shared'; + +import { TaurusSharedNgRxModule } from '@vdk/shared'; + +import { AttributesDirective } from './shared/directives'; + +import { + ContactsPresentPipe, + ExecutionSuccessRatePipe, + ExtractContactsPipe, + ExtractJobStatusPipe, + FormatDeltaPipe, + FormatSchedulePipe, + ParseEpochPipe, + ParseNextRunPipe +} from './shared/pipes'; + +import { + ColumnFilterComponent, + ConfirmationDialogModalComponent, + DeleteModalComponent, + EmptyStateComponent, + ExecutionsTimelineComponent, + GridActionComponent, + QuickFiltersComponent, + StatusCellComponent, + StatusPanelComponent, + WidgetValueComponent +} from './shared/components'; + +import { DataJobsApiService, DataJobsBaseApiService, DataJobsPublicApiService, DataJobsService, DataJobsServiceImpl } from './services'; + +import { DATA_PIPELINES_CONFIGS, DataPipelinesConfig } from './model'; + +import { DataJobsEffects } from './state/effects'; + +import { FormatDurationPipe } from './shared/pipes/format-duration.pipe'; + +import { DataJobsExplorePageComponent } from './components/data-jobs-explore'; +import { DataJobsExploreGridComponent } from './components/data-jobs-explore/components/grid'; + +import { DataJobsManagePageComponent } from './components/data-jobs-manage'; +import { DataJobsManageGridComponent } from './components/data-jobs-manage/components/grid'; + +import { DataJobPageComponent } from './components/data-job'; +import { DataJobDetailsPageComponent } from './components/data-job/pages/details'; +import { + DataJobDeploymentDetailsModalComponent, + DataJobExecutionsGridComponent, + DataJobExecutionsPageComponent, + DataJobExecutionStatusComponent, + DataJobExecutionStatusFilterComponent, + DataJobExecutionTypeComponent, + DataJobExecutionTypeFilterComponent, + ExecutionDurationChartComponent, + ExecutionStatusChartComponent, + TimePeriodFilterComponent +} from './components/data-job/pages/executions'; + +import { + DataJobsExecutionsWidgetComponent, + DataJobsFailedWidgetComponent, + DataJobsHealthPanelComponent, + DataJobsWidgetOneComponent, + WidgetExecutionStatusGaugeComponent +} from './components/widgets'; + +const routes: Routes = []; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild(routes), + VmwComponentsModule.forRoot(), + ClrDatagridModule, + ClrSpinnerModule, + ClarityModule, + LottieModule, + FormsModule, + ReactiveFormsModule, + TruncateModule, + TimeagoModule.forRoot(), + TaurusSharedNgRxModule.forFeatureEffects([DataJobsEffects]), + DpDatePickerModule, + NgxChartsModule + ], + declarations: [ + AttributesDirective, + FormatDeltaPipe, + FormatSchedulePipe, + ParseNextRunPipe, + ContactsPresentPipe, + ExecutionSuccessRatePipe, + ExtractJobStatusPipe, + ExtractContactsPipe, + ParseEpochPipe, + DataJobsExplorePageComponent, + DataJobsExploreGridComponent, + DataJobsManagePageComponent, + DataJobsManageGridComponent, + DataJobPageComponent, + DataJobDetailsPageComponent, + DataJobExecutionsPageComponent, + DataJobExecutionTypeComponent, + DataJobExecutionStatusFilterComponent, + DataJobDeploymentDetailsModalComponent, + DataJobExecutionsGridComponent, + DataJobExecutionTypeFilterComponent, + TimePeriodFilterComponent, + ExecutionStatusChartComponent, + ExecutionDurationChartComponent, + DataJobExecutionStatusComponent, + DeleteModalComponent, + ConfirmationDialogModalComponent, + GridActionComponent, + StatusCellComponent, + StatusPanelComponent, + ExecutionsTimelineComponent, + // Widgets + DataJobsWidgetOneComponent, + WidgetValueComponent, + ColumnFilterComponent, + FormatDurationPipe, + QuickFiltersComponent, + DataJobsExecutionsWidgetComponent, + DataJobsFailedWidgetComponent, + WidgetExecutionStatusGaugeComponent, + DataJobsHealthPanelComponent, + EmptyStateComponent + ], + exports: [ + DataJobsExplorePageComponent, + DataJobsExploreGridComponent, + DataJobsManagePageComponent, + DataJobsManageGridComponent, + DataJobPageComponent, + DataJobDetailsPageComponent, + DataJobExecutionsPageComponent, + DataJobsWidgetOneComponent, + DataJobsExecutionsWidgetComponent, + DataJobsFailedWidgetComponent, + WidgetExecutionStatusGaugeComponent, + DataJobsHealthPanelComponent + ] +}) +export class DataPipelinesModule { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static forRoot(config: DataPipelinesConfig = {} as any): ModuleWithProviders { + return { + ngModule: DataPipelinesModule, + providers: [ + DataJobsBaseApiService, + DataJobsPublicApiService, + DataJobsApiService, + { provide: DataJobsService, useClass: DataJobsServiceImpl }, + { provide: DATA_PIPELINES_CONFIGS, useValue: config } + ] + }; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/config.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/config.model.ts new file mode 100644 index 0000000000..6e60334590 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/config.model.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from 'rxjs'; + +import { DisplayMode } from './grid-config.model'; + +export interface DataPipelinesConfig { + defaultOwnerTeamName: string; + ownerTeamNamesObservable?: Observable; + exploreConfig?: ExploreConfig; + manageConfig?: ManageConfig; + // health status url configured by a segment after hostname, including slash with {0} for the id param, + healthStatusUrl?: string; // eg: /dev-center/health-status?dataJob={0} + /** + * @deprecated + */ + showLogsInsightUrl?: boolean; + /** + * @deprecated + */ + showExecutionsPage?: boolean; + + /** + * ** Flag instruction to show or hide tab for lineage page. + */ + showLineagePage?: boolean; +} + +export interface ExploreConfig { + showTeamsColumn?: boolean; +} + +export interface ManageConfig { + selectedTeamNameObservable?: Observable; + filterByTeamName?: boolean; + displayMode?: DisplayMode; + allowKeyTabDownloads?: boolean; + showTeamsColumn?: boolean; + ensureMembershipEarlyAccessProgram?: (key: string) => boolean; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/constants.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/constants.model.ts new file mode 100644 index 0000000000..a6245062b2 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/constants.model.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; + +import { DataPipelinesConfig } from './config.model'; + +/** + * ** Injection Token for Data pipelines config. + */ +export const DATA_PIPELINES_CONFIGS = new InjectionToken('DataPipelinesConfig'); + +/** + * ** Team name constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const TEAM_NAME_REQ_PARAM = 'team-name-req-param'; + +/** + * ** Data Job name constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const JOB_NAME_REQ_PARAM = 'job-name-req-param'; + +/** + * ** Data Job deployment ID constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const JOB_DEPLOYMENT_ID_REQ_PARAM = 'job-deployment-id-req-param'; + +/** + * ** Data Job status constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const JOB_STATUS_REQ_PARAM = 'job-status-req-param'; + +/** + * ** Filter constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const FILTER_REQ_PARAM = 'filter-req-param'; + +/** + * ** Order constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const ORDER_REQ_PARAM = 'order-req-param'; + +/** + * ** Data Job details constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const JOB_DETAILS_REQ_PARAM = 'job-details-req-param'; + +/** + * ** Data Job state constant used as key identifier in {@link ComponentState.requestParams}. + */ +export const JOB_STATE_REQ_PARAM = 'job-state-req-param'; + +/** + * ** Data Job state constant used as key identifier in {@link ComponentState.data} + */ +export const JOB_STATE_DATA_KEY = 'job-state-data-key'; + +/** + * ** Data Jobs states constant used as key identifier in {@link ComponentState.data} + */ +export const JOBS_DATA_KEY = 'jobs-data-key'; + +/** + * ** Data Job details constant used as key identifier in {@link ComponentState.data} + */ +export const JOB_DETAILS_DATA_KEY = 'job-details-data-key'; + +/** + * ** Data Job Executions constant used as key identifier in {@link ComponentState.data} + */ +export const JOB_EXECUTIONS_DATA_KEY = 'job-executions-data-key'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-base.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-base.model.ts new file mode 100644 index 0000000000..44b9ab1c2f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-base.model.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +export interface StatusDetails { + enabled: boolean; +} + +export interface GraphQLResponsePage { + content?: T[]; + totalItems?: number; + totalPages?: number; +} + +// Deployment + +export interface BaseDataJobDeployment extends StatusDetails { + id: string; + contacts?: DataJobContacts; + jobVersion?: string; + deployedDate?: string; + deployedBy?: string; + mode?: string; + resources?: DataJobResources; + schedule?: DataJobSchedule; + vdkVersion?: string; + status?: DataJobDeploymentStatus; + executions?: E[]; +} + +export enum DataJobDeploymentStatus { + NONE = 'NONE', + SUCCESS = 'SUCCESS', + PLATFORM_ERROR = 'PLATFORM_ERROR', + USER_ERROR = 'USER_ERROR' +} + +export interface DataJobContacts { + notifiedOnJobFailureUserError: string[]; + notifiedOnJobFailurePlatformError: string[]; + notifiedOnJobSuccess: string[]; + notifiedOnJobDeploy: string[]; +} + +export interface DataJobSchedule { + scheduleCron?: string; + nextRunEpochSeconds?: number; +} + +export interface DataJobResources { + cpuLimit: number; + cpuRequest: number; + memoryLimit: number; + memoryRequest: number; + ephemeralStorageLimit?: number; + ephemeralStorageRequest?: number; + netBandwidthLimit?: number; +} + +// Execution + +export interface DataJobExecution { + id: string; + type?: DataJobExecutionType; + jobName?: string; + status?: DataJobExecutionStatus; + startTime?: string; + endTime?: string; + startedBy?: string; + message?: string; + opId?: string; + logsUrl?: string; + deployment?: BaseDataJobDeployment; +} + +export enum DataJobExecutionType { + MANUAL = 'MANUAL', + SCHEDULED = 'SCHEDULED' +} + +/** + * ** Execution Status. + */ +export enum DataJobExecutionStatus { + SUBMITTED = 'SUBMITTED', + RUNNING = 'RUNNING', + FINISHED = 'FINISHED', // Keep for backward compatibility + SUCCEEDED = 'SUCCEEDED', + CANCELLED = 'CANCELLED', + SKIPPED = 'SKIPPED', + FAILED = 'FAILED', // Keep for backward compatibility + USER_ERROR = 'USER_ERROR', + PLATFORM_ERROR = 'PLATFORM_ERROR' +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-deployments.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-deployments.model.ts new file mode 100644 index 0000000000..56c31ee157 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-deployments.model.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseDataJobDeployment, DataJobExecutionStatus } from './data-job-base.model'; + +export interface DataJobDeployment extends BaseDataJobDeployment { + lastDeployedDate?: string; + lastDeployedBy?: string; + lastExecutionStatus?: DataJobExecutionStatus; + lastExecutionDuration?: number; + lastExecutionTime?: string; + successfulExecutions?: number; + failedExecutions?: number; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-details.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-details.model.ts new file mode 100644 index 0000000000..081194cb9a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-details.model.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { StatusDetails } from './data-job-base.model'; + +import { DataJobExecutionStatusDeprecated } from './data-job-executions.model'; + +/** + * ** Data job details. + * + * @deprecated + */ +export interface DataJobDetails { + job_name?: string; + team?: string; + description?: string; + config?: DataJobConfigDetails; +} + +/** + * ** Data job config details. + * + * @deprecated + */ +export interface DataJobConfigDetails { + schedule?: DataJobScheduleDetails; + contacts?: DataJobContactsDetails; +} + +/** + * ** Data job execution. + * + * @deprecated + */ +export interface DataJobExecutionDetails { + id: string; + job_name: string; + type: 'manual' | 'scheduled'; + status: DataJobExecutionStatusDeprecated; + start_time: string; + started_by: string; + end_time: string; + op_id: string; + message: string; + logs_url: string; + deployment?: DataJobDeploymentDetails; +} + +/** + * ** Data job deployment. + * + * @deprecated + */ +export interface DataJobDeploymentDetails extends StatusDetails { + id: string; + job_version: string; + mode: string; + vdk_version: string; + deployed_by: string; + deployed_date: string; + resources: { + cpu_request: number; + cpu_limit: number; + memory_limit: number; + memory_request: number; + }; + contacts?: DataJobContactsDetails; + schedule?: DataJobScheduleDetails; +} + +/** + * ** Data job schedule details. + * + * @deprecated + */ +export interface DataJobScheduleDetails { + schedule_cron: string; +} + +/** + * ** Data job contacts details. + * + * @deprecated + */ +export interface DataJobContactsDetails { + notified_on_job_deploy: string[]; + notified_on_job_failure_platform_error: string[]; + notified_on_job_failure_user_error: string[]; + notified_on_job_success: string[]; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-executions.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-executions.model.ts new file mode 100644 index 0000000000..98ca57c7e7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job-executions.model.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { DirectionType } from '@vdk/shared'; + +import { DataJobExecution, DataJobExecutionStatus, GraphQLResponsePage } from './data-job-base.model'; + +export type DataJobExecutions = DataJobExecution[]; + +/** + * ** Execution status. + * + * @deprecated + */ +// eslint-disable-next-line no-shadow +export enum DataJobExecutionStatusDeprecated { + SUBMITTED = 'submitted', + RUNNING = 'running', + FINISHED = 'finished', // Keep for backward compatibility + SUCCEEDED = 'succeeded', + CANCELLED = 'cancelled', + SKIPPED = 'skipped', + FAILED = 'failed', // Keep for backward compatibility + USER_ERROR = 'user_error', + PLATFORM_ERROR = 'platform_error' +} + +/** + * ** Request variables fro DataJobs Executions jobsQuery GraphQL API. + */ +export interface DataJobExecutionsReqVariables { + pageNumber?: number; + pageSize?: number; + filter?: DataJobExecutionFilter; + order?: DataJobExecutionOrder; +} + +export interface DataJobExecutionFilter { + statusIn?: DataJobExecutionStatus[]; + jobNameIn?: string[]; + teamNameIn?: string[]; + startTimeGte?: string | Date; + endTimeGte?: string | Date; + startTimeLte?: string | Date; + endTimeLte?: string | Date; +} + +export interface DataJobExecutionOrder { + property: keyof DataJobExecution; + direction: DirectionType; +} + +export type DataJobExecutionsPage = GraphQLResponsePage; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job.model.ts new file mode 100644 index 0000000000..fde9e0a264 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/data-job.model.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { ApiPredicate } from '@vdk/shared'; + +import { DataJobContacts, DataJobSchedule, GraphQLResponsePage } from './data-job-base.model'; + +import { DataJobDeployment } from './data-job-deployments.model'; + +export type DataJobPage = GraphQLResponsePage; + +export interface DataJob { + jobName?: string; + config?: DataJobConfig; + deployments?: DataJobDeployment[]; +} + +export interface DataJobConfig { + team?: string; + description?: string; + generateKeytab?: boolean; + sourceUrl?: string; + logsUrl?: string; + schedule?: DataJobSchedule; + contacts?: DataJobContacts; +} + +/** + * ** Request variables for DataJobs jobsQuery GraphQL API. + */ +export interface DataJobReqVariables { + pageNumber?: number; + pageSize?: number; + filter?: ApiPredicate[]; + search?: string; +} + +export enum DataJobStatus { + ENABLED = 'Enabled', + DISABLED = 'Disabled', + NOT_DEPLOYED = 'Not Deployed' +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/grid-config.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/grid-config.model.ts new file mode 100644 index 0000000000..dfd0b2e82f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/grid-config.model.ts @@ -0,0 +1,11 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum DisplayMode { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + COMPACT = 'compact', + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + STANDARD = 'standard' +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/index.ts new file mode 100644 index 0000000000..caba733699 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './config.model'; +export * from './constants.model'; +export * from './data-job-base.model'; +export * from './data-job.model'; +export * from './data-job-deployments.model'; +export * from './data-job-details.model'; +export * from './data-job-executions.model'; +export * from './grid-config.model'; +export * from './route.model'; +export * from './toast-definitions.model'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/public-api.ts new file mode 100644 index 0000000000..ca2f5606ea --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/public-api.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job-base.model'; + +export * from './data-job.model'; + +export * from './data-job-details.model'; + +export * from './data-job-deployments.model'; + +export * from './data-job-executions.model'; + +export * from './grid-config.model'; + +export * from './config.model'; + +export * from './route.model'; + +export * from './toast-definitions.model'; + +export { DATA_PIPELINES_CONFIGS } from './constants.model'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/route.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/route.model.ts new file mode 100644 index 0000000000..8acffa5dc1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/route.model.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ArrayElement, + TaurusNavigateAction, + TaurusRouteData, + TaurusRouteNavigateBackData, + TaurusRouteNavigateToData, + TaurusRoutes +} from '@vdk/shared'; + +export interface DataPipelinesRestoreUI { + /** + * ** Restore when this condition is met, previous ConfigPath equals to provided. + */ + previousConfigPathLike: string; +} + +/** + * ** Data pipelines Route data. + */ +export interface DataPipelinesRouteData extends TaurusRouteNavigateToData, TaurusRouteNavigateBackData { + /** + * ** Field that has pointer to paramKey for Team in Route config. + */ + teamParamKey?: string; + + /** + * ** Field that has pointer to paramKey for Job in Route config. + */ + jobParamKey?: string; + + /** + * ** Field flag that enable/disable Listener for Team Change and on Change to do some action. + */ + activateListenerForTeamChange?: boolean; + + /** + * ** Field flag that enable/disable subpage navigation. + * + * - true - enables subpage navigation + * - false - disable subpage navigation and activate default root Page navigation. + */ + activateSubpageNavigation?: boolean; + + /** + * @inheritDoc + */ + navigateTo?: TaurusNavigateAction; + + /** + * @inheritDoc + */ + navigateBack?: TaurusNavigateAction; + + /** + * ** Field that instruct Component when should restore UI. + */ + restoreUiWhen?: DataPipelinesRestoreUI; + + /** + * ** Configuring this field, instruct Component on this Route to be in editable mode or no. + * + * - true -> Component is in editable mode. + * - false -> Component is in readonly mode. + */ + editable?: boolean; +} + +/** + * ** Data pipelines Route config. + */ +export type DataPipelinesRoute = ArrayElement; + +/** + * ** Data pipelines Routes configs. + */ +export type DataPipelinesRoutes = TaurusRoutes>; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/toast-definitions.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/toast-definitions.model.ts new file mode 100644 index 0000000000..21f6f14680 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/model/toast-definitions.model.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VmwToastType } from '@vdk/shared'; + +import { Toast } from '@vdk/shared'; + +export class ToastDefinitions { + static successfullyRanJob(jobName: string): Toast { + return { + type: VmwToastType.INFO, + title: `Data job Queued for execution`, + description: `Data job "${ jobName }" successfully queued for execution.` + }; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-base.api.service.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-base.api.service.spec.ts new file mode 100644 index 0000000000..6ed1809a2b --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-base.api.service.spec.ts @@ -0,0 +1,294 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient } from '@angular/common/http'; + +import { TestBed } from '@angular/core/testing'; + +import { of } from 'rxjs'; + +import { ApolloQueryResult, gql, InMemoryCache } from '@apollo/client/core'; + +import { Apollo, ApolloBase, QueryRef } from 'apollo-angular'; +import { HttpLink, HttpLinkHandler } from 'apollo-angular/http'; + +import { ErrorHandlerService } from '@vdk/shared'; + +import { DataJobExecutionsPage, DataJobExecutionsReqVariables, DataJobPage, DataJobReqVariables } from '../model'; + +import { DataJobsBaseApiService } from './data-jobs-base.api.service'; + +describe('DataJobsBaseApiService', () => { + let service: DataJobsBaseApiService; + let apolloStub: jasmine.SpyObj; + let httpLinkStub: jasmine.SpyObj; + let httpClientStub: jasmine.SpyObj; + let errorHandlerServiceStub: jasmine.SpyObj; + + let apolloBaseStub: jasmine.SpyObj; + + beforeEach(() => { + apolloStub = jasmine.createSpyObj('apolloService', ['use', 'createNamed']); + httpLinkStub = jasmine.createSpyObj('httpLinkService', ['create']); + httpClientStub = jasmine.createSpyObj('httpClientService', ['request']); + errorHandlerServiceStub = jasmine.createSpyObj('errorHandlerService', [ + 'processError', + 'handleError' + ]); + + apolloBaseStub = jasmine.createSpyObj('apolloBase', ['query', 'watchQuery']); + + TestBed.configureTestingModule({ + providers: [ + DataJobsBaseApiService, + { provide: Apollo, useValue: apolloStub }, + { provide: HttpLink, useValue: httpLinkStub }, + { provide: HttpClient, useValue: httpClientStub }, + { provide: ErrorHandlerService, useValue: errorHandlerServiceStub } + ] + }); + + service = TestBed.inject(DataJobsBaseApiService); + }); + + it('should verify service instance is created', () => { + // Then + expect(service).toBeTruthy(); + }); + + describe('Methods::', () => { + describe('|getJobs|', () => { + it('should verify will make expected calls', () => { + // Given + const gqlQuery = `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + description + sourceUrl + } + } + totalPages + totalItems + } + }`; + const ownerTeam = 'supercollider_test'; + const dataJobReqVariables: DataJobReqVariables = { + pageNumber: 2, + pageSize: 25, + filter: [], + search: 'sup' + }; + const apolloLinkHandlerStub: HttpLinkHandler = {} as any; + const apolloMockResponse: ApolloQueryResult = { + data: { + content: [], + totalItems: 25, + totalPages: 1 + }, + networkStatus: 7, + loading: false + }; + + apolloBaseStub.query.and.returnValue(of(apolloMockResponse)); + apolloStub.use.and.returnValues(null, apolloBaseStub); + httpLinkStub.create.and.returnValue(apolloLinkHandlerStub); + + // When + let dataJobPage: DataJobPage; + service + .getJobs(ownerTeam, gqlQuery, dataJobReqVariables) + .subscribe((r) => dataJobPage = r.data); + + // Then + expect(apolloStub.use.calls.argsFor(0)).toEqual([ownerTeam]); + expect(httpLinkStub.create).toHaveBeenCalledWith({ + uri: `/data-jobs/for-team/${ ownerTeam }/jobs`, + method: 'GET' + }); + expect(apolloStub.createNamed.calls.argsFor(0)[0]).toEqual(ownerTeam); + expect(apolloStub.createNamed.calls.argsFor(0)[1].cache).toEqual(jasmine.any(InMemoryCache)); + expect(apolloStub.createNamed.calls.argsFor(0)[1].link).toBe(apolloLinkHandlerStub); + expect(apolloStub.createNamed.calls.argsFor(0)[1].defaultOptions).toEqual({ + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } + }); + expect(apolloStub.use.calls.argsFor(1)).toEqual([ownerTeam]); + expect(apolloBaseStub.query).toHaveBeenCalledWith({ + query: gql`${ gqlQuery }`, + variables: dataJobReqVariables + }); + expect(dataJobPage).toBe(apolloMockResponse.data); + }); + }); + + describe('|watchForJobs|', () => { + it('should verify will make expected calls', () => { + // Given + const gqlQuery = `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + description + sourceUrl + } + } + totalPages + totalItems + } + }`; + const ownerTeam = 'supercollider_test'; + const dataJobReqVariables: DataJobReqVariables = { + pageNumber: 2, + pageSize: 25, + filter: [], + search: 'sup' + }; + const apolloLinkHandlerStub = {} as HttpLinkHandler; + const apolloQueryResult: ApolloQueryResult = { + data: { + content: [], + totalItems: 25, + totalPages: 1 + }, + networkStatus: 7, + loading: false + }; + const apolloMockResponse: QueryRef = { + valueChanges: of(apolloQueryResult) + } as QueryRef; + + apolloBaseStub.watchQuery.and.returnValue(apolloMockResponse); + apolloStub.use.and.returnValues(null, apolloBaseStub); + httpLinkStub.create.and.returnValue(apolloLinkHandlerStub); + + // When + let dataJobPage: DataJobPage; + service + .watchForJobs(ownerTeam, gqlQuery, dataJobReqVariables) + .valueChanges + .subscribe((r) => dataJobPage = r.data); + + // Then + expect(apolloStub.use.calls.argsFor(0)).toEqual([ownerTeam]); + expect(httpLinkStub.create).toHaveBeenCalledWith({ + uri: `/data-jobs/for-team/${ ownerTeam }/jobs`, + method: 'GET' + }); + expect(apolloStub.createNamed.calls.argsFor(0)[0]).toEqual(ownerTeam); + expect(apolloStub.createNamed.calls.argsFor(0)[1].cache).toEqual(jasmine.any(InMemoryCache)); + expect(apolloStub.createNamed.calls.argsFor(0)[1].link).toBe(apolloLinkHandlerStub); + expect(apolloStub.createNamed.calls.argsFor(0)[1].defaultOptions).toEqual({ + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } + }); + expect(apolloStub.use.calls.argsFor(1)).toEqual([ownerTeam]); + expect(apolloBaseStub.watchQuery).toHaveBeenCalledWith({ + query: gql`${ gqlQuery }`, + variables: dataJobReqVariables + }); + expect(dataJobPage).toBe(apolloQueryResult.data); + }); + }); + + describe('|getExecutions|', () => { + it('should verify will make expected calls', () => { + // Given + // eslint-disable-next-line max-len + const gqlQuery = `query jobsQuery($pageNumber: Int, $pageSize: Int, $filter: DataJobExecutionFilter, $order: DataJobExecutionOrder) + { + executions(pageNumber: $pageNumber, pageSize: $pageSize, filter: $filter, order: $order) { + content { + id + type + jobName + status + startTime + deployment { + enabled + jobVersion + resources { + cpuLimit + cpuRequest + } + } + } + totalPages + totalItems + } + }`; + const ownerTeam = 'supercollider_test'; + const dataJobReqVariables: DataJobExecutionsReqVariables = { + pageNumber: 2, + pageSize: 25 + }; + const apolloLinkHandlerStub = {} as HttpLinkHandler; + const apolloMockResponse: ApolloQueryResult = { + data: { + content: [], + totalItems: 25, + totalPages: 1 + }, + networkStatus: 7, + loading: false + }; + + apolloBaseStub.query.and.returnValue(of(apolloMockResponse)); + apolloStub.use.and.returnValues(null, apolloBaseStub); + httpLinkStub.create.and.returnValue(apolloLinkHandlerStub); + + // When + let dataJobExecutionsPage: DataJobExecutionsPage; + service + .getExecutions(ownerTeam, gqlQuery, dataJobReqVariables) + .subscribe((r) => dataJobExecutionsPage = r.data); + + // Then + expect(apolloStub.use.calls.argsFor(0)).toEqual([ownerTeam]); + expect(httpLinkStub.create).toHaveBeenCalledWith({ + uri: `/data-jobs/for-team/${ ownerTeam }/jobs`, + method: 'GET' + }); + expect(apolloStub.createNamed.calls.argsFor(0)[0]).toEqual(ownerTeam); + expect(apolloStub.createNamed.calls.argsFor(0)[1].cache).toEqual(jasmine.any(InMemoryCache)); + expect(apolloStub.createNamed.calls.argsFor(0)[1].link).toBe(apolloLinkHandlerStub); + expect(apolloStub.createNamed.calls.argsFor(0)[1].defaultOptions).toEqual({ + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } + }); + expect(apolloStub.use.calls.argsFor(1)).toEqual([ownerTeam]); + expect(apolloBaseStub.query).toHaveBeenCalledWith({ + query: gql`${ gqlQuery }`, + variables: dataJobReqVariables + }); + expect(dataJobExecutionsPage).toBe(apolloMockResponse.data); + }); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-base.api.service.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-base.api.service.ts new file mode 100644 index 0000000000..cc4ef6607a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-base.api.service.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { ApolloQueryResult, DefaultOptions, gql, InMemoryCache } from '@apollo/client/core'; + +import { Apollo, ApolloBase, QueryRef } from 'apollo-angular'; +import { HttpLink } from 'apollo-angular/http'; + +import { DataJob, DataJobExecutionsPage, DataJobExecutionsReqVariables, DataJobPage, DataJobReqVariables } from '../model'; + +/** + * ** Data Jobs Service build on top of Apollo gql client. + */ +@Injectable() +export class DataJobsBaseApiService { + private static readonly APOLLO_METHOD = 'GET'; + private static readonly APOLLO_DEFAULT_OPTIONS: DefaultOptions = { + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } + }; + + /** + * ** Constructor. + */ + constructor(private readonly apollo: Apollo, + private readonly httpLink: HttpLink) { + } + + /** + * ** Get all DataJobs for provided OwnerTeam and load data based on provided gqlQuery. + */ + getJobs( + ownerTeam: string, + gqlQuery: string, + variables: DataJobReqVariables): Observable> { + + return this.getApolloClientFor(ownerTeam) + .query({ + query: gql`${ gqlQuery }`, + variables + }); + } + + /** + * ** Create Apollo watcher for gqlQuery. + */ + watchForJobs( + ownerTeam: string, + gqlQuery: string, + variables: DataJobReqVariables): QueryRef { + + return this.getApolloClientFor(ownerTeam) + .watchQuery({ + query: gql`${ gqlQuery }`, + variables + }); + } + + /** + * ** Get all DataJob Executions for provided OwnerTeam and load data based on provided gqlQuery. + */ + getExecutions( // NOSONAR + ownerTeam: string, + gqlQuery: string, + variables: DataJobExecutionsReqVariables + ): Observable> { + + return this.getApolloClientFor(ownerTeam) + .query({ + query: gql`${ gqlQuery }`, + variables + }); + } + + private getApolloClientFor(ownerTeam: string): ApolloBase { + if (!this.apollo.use(ownerTeam)) { + this.apollo.createNamed(ownerTeam, { + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + jobs: (_existing, _options) => { + return {}; + }, + executions: (_existing, _options) => { + return {}; + } + } + } + } + }), + link: this.httpLink.create({ + uri: `/data-jobs/for-team/${ ownerTeam }/jobs`, + method: DataJobsBaseApiService.APOLLO_METHOD + }), + defaultOptions: DataJobsBaseApiService.APOLLO_DEFAULT_OPTIONS + }); + } + + return this.apollo.use(ownerTeam) as ApolloBase; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-public.api.service.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-public.api.service.spec.ts new file mode 100644 index 0000000000..2f40653383 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-public.api.service.spec.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; + +import { forkJoin, of, Subject } from 'rxjs'; + +import { ApolloQueryResult } from '@apollo/client/core'; + +import { ApiPredicate } from '@vdk/shared'; + +import { DataJobPage } from '../model'; + +import { DataJobsBaseApiService } from './data-jobs-base.api.service'; +import { DataJobsPublicApiService } from './data-jobs-public.api.service'; + +describe('DataJobsPublicApiService', () => { + let dataJobsBaseServiceStub: jasmine.SpyObj; + + let service: DataJobsPublicApiService; + + beforeEach(() => { + dataJobsBaseServiceStub = jasmine.createSpyObj('dataJobsBaseServiceStub', ['getJobs']); + dataJobsBaseServiceStub.getJobs.and.returnValue(new Subject>()); + + TestBed.configureTestingModule({ + providers: [ + DataJobsPublicApiService, + { provide: DataJobsBaseApiService, useValue: dataJobsBaseServiceStub } + ] + }); + service = TestBed.inject(DataJobsPublicApiService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Methods::', () => { + describe('|getAllDataJobs|', () => { + it('should verify will make expected calls scenario 1', () => { + // Given + const apolloQueryResult: ApolloQueryResult = { + data: { + content: [{}], + totalItems: 1, + totalPages: 1 + }, + loading: false, + networkStatus: 7 + }; + const apolloQueryRef = of(apolloQueryResult); + + dataJobsBaseServiceStub.getJobs.and.returnValue(apolloQueryRef); + + // When + service.getAllDataJobs('teamA') + .subscribe(); + + // Then + expect(dataJobsBaseServiceStub.getJobs).toHaveBeenCalledWith( + 'teamA', + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + description + sourceUrl + } + } + totalPages + totalItems + } + }`, + { + filter: [], + search: null, + pageNumber: 1, + pageSize: 1000 + } + ); + }); + + it('should verify will make expected calls scenario 2', (done) => { + // Given + const apolloQueryResult: ApolloQueryResult = { + data: { + content: [{}], + totalItems: 2500, + totalPages: 3 + }, + loading: false, + networkStatus: 7 + }; + const apolloQueryRef = of(apolloQueryResult); + let counter = 0; + + dataJobsBaseServiceStub.getJobs.and.returnValue(apolloQueryRef); + + // When/Then + service.getAllDataJobs('teamA') + .subscribe((value) => { + expect(dataJobsBaseServiceStub.getJobs.calls.argsFor(counter)).toEqual([ + 'teamA', + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + description + sourceUrl + } + } + totalPages + totalItems + } + }`, + { + filter: [], + search: null, + pageNumber: counter + 1, + pageSize: 1000 + } + ]); + + counter++; + + expect(value?.length).toEqual(counter); + + if (counter === 3) { + done(); + } + }); + }); + }); + + describe('|getDataJobsTotalForTeam|', () => { + it('should verify will make expected calls', (done) => { + // Given + const apolloQueryResult: ApolloQueryResult = { + data: { + content: [{}, {}, {}, {}, {}], + totalItems: 5, + totalPages: 1 + }, + loading: false, + networkStatus: 7 + }; + const assertionFilters: ApiPredicate[] = [ + { + property: 'config.team', + pattern: 'teamA', + sort: null + } + ]; + + dataJobsBaseServiceStub.getJobs.and.returnValues( + of(apolloQueryResult), + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + of(undefined), + of({ + ...apolloQueryResult, + data: undefined + }), + of({ + ...apolloQueryResult, + data: { + ...apolloQueryResult.data, + totalItems: undefined + } + }) + ); + + // When/Then + forkJoin([ + service.getDataJobsTotal('teamA'), + service.getDataJobsTotal('teamA'), + service.getDataJobsTotal('teamA'), + service.getDataJobsTotal('teamA') + ]).subscribe(([value1, value2, value3, value4]) => { + expect(value1).toEqual(5); + expect(value2).toEqual(0); + expect(value3).toEqual(0); + expect(value4).toEqual(0); + expect(dataJobsBaseServiceStub.getJobs).toHaveBeenCalledWith( + 'teamA', + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + } + } + totalPages + totalItems + } + }`, + { + filter: assertionFilters, + search: null, + pageNumber: 1, + pageSize: 1 + } + ); + + done(); + }); + }); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-public.api.service.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-public.api.service.ts new file mode 100644 index 0000000000..804414775c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs-public.api.service.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; + +import { EMPTY, expand, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ApiPredicate } from '@vdk/shared'; + +import { DataJob, DataJobPage } from '../model'; + +import { DataJobsBaseApiService } from './data-jobs-base.api.service'; + +@Injectable() +export class DataJobsPublicApiService { + /** + * ** Constructor. + */ + constructor(private readonly dataJobsBaseService: DataJobsBaseApiService) { + } + + /** + * ** Retrieve all DataJobs for Team. + */ + getAllDataJobs(team: string): Observable> { + const pageSize = 1000; + let pageNumber = 1; + let dataJobs: DataJob[] = []; + + return this._getDataJobsPage(team, pageNumber, pageSize) + .pipe( + expand((dataJobPage) => { + if (dataJobPage.totalPages <= pageNumber) { + return EMPTY; + } else { + return this._getDataJobsPage(team, ++pageNumber, pageSize); + } + }), + map((dataJobPage) => { + dataJobs = dataJobs.concat( + (dataJobPage.content as unknown) as DataJob[] + ); + + return dataJobs; + }) + ); + } + + /** + * ** Get total number of Data Jobs assets for Team. + */ + getDataJobsTotal(team: string): Observable { + const filters: ApiPredicate[] = [ + { + property: 'config.team', + pattern: team, + sort: null + } + ]; + + return this.dataJobsBaseService + .getJobs( + team, + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + } + } + totalPages + totalItems + } + }`, + { + filter: filters, + search: null, + pageNumber: 1, + pageSize: 1 + } + ) + .pipe( + map((response) => response?.data?.totalItems ?? 0) + ); + } + + /** + * ** Retrieve the data-jobs page. + */ + private _getDataJobsPage( + team: string, + pageNumber: number, + pageSize: number, + filters: ApiPredicate[] = [], + searchQueryValue: string = null + ): Observable { + return this.dataJobsBaseService + .getJobs( + team, + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(filter: $filter, search: $search, pageNumber: $pageNumber, pageSize: $pageSize) { + content { + jobName + config { + team + description + sourceUrl + } + } + totalPages + totalItems + } + }`, + { + filter: filters, + search: searchQueryValue, + pageNumber, + pageSize + } + ) + .pipe( + map((response) => response.data) + ); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.api.service.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.api.service.spec.ts new file mode 100644 index 0000000000..6e16471a36 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.api.service.spec.ts @@ -0,0 +1,388 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; + +import { of } from 'rxjs'; + +import { ApolloQueryResult } from '@apollo/client/core'; + +import { DATA_PIPELINES_CONFIGS, DataJob, DataJobPage } from '../model'; + +import { DataJobsBaseApiService } from './data-jobs-base.api.service'; + +import { DataJobsApiService, MISSING_DEFAULT_TEAM_MESSAGE, RESERVED_DEFAULT_TEAM_NAME_MESSAGE } from './data-jobs.api.service'; + +describe('DataJobsApiService', () => { + let service: DataJobsApiService; + let dataJobsBaseServiceStub: jasmine.SpyObj; + let httpClientStub: jasmine.SpyObj; + + const TEST_JOB_DETAILS = { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + job_name: 'job001', + team: 'taurus', + description: 'descpription001' + }; + + beforeEach(() => { + dataJobsBaseServiceStub = jasmine.createSpyObj('dataJobsBaseService', ['getJobs']); + httpClientStub = jasmine.createSpyObj('httpClient', ['get', 'post', 'patch', 'put', 'delete']); + + httpClientStub.get.and.returnValue(of({})); + httpClientStub.post.and.returnValue(of({})); + httpClientStub.patch.and.returnValue(of({})); + httpClientStub.put.and.returnValue(of({})); + httpClientStub.delete.and.returnValue(of({})); + + TestBed.configureTestingModule({ + providers: [ + DataJobsApiService, + { provide: HttpClient, useValue: httpClientStub }, + { provide: DataJobsBaseApiService, useValue: dataJobsBaseServiceStub }, + { + provide: DATA_PIPELINES_CONFIGS, + useFactory: () => ({ + defaultOwnerTeamName: 'all' + }) + } + ] + }); + + service = TestBed.inject(DataJobsApiService); + }); + + it('can load instance', () => { + expect(service).toBeTruthy(); + }); + + describe('Methods::', () => { + describe('|getJobs|', () => { + it('should verify will make expected calls', () => { + // Given + const apolloQueryResult: ApolloQueryResult = { + data: { + content: [{}], + totalItems: 1, + totalPages: 1 + }, + loading: false, + networkStatus: 7 + }; + const apolloQueryRef = of(apolloQueryResult); + + dataJobsBaseServiceStub.getJobs.and.returnValue(apolloQueryRef); + + // When + service.getJobs([], 'searchQueryValue', 1, 1); + + // Then + expect(dataJobsBaseServiceStub.getJobs).toHaveBeenCalledWith( + 'all', + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(pageNumber: $pageNumber, pageSize: $pageSize, filter: $filter, search: $search) { + content { + jobName + config { + team + description + sourceUrl + schedule { + scheduleCron + nextRunEpochSeconds + } + contacts { + notifiedOnJobSuccess + notifiedOnJobDeploy + notifiedOnJobFailureUserError + notifiedOnJobFailurePlatformError + } + } + deployments { + id + enabled + lastDeployedDate + lastDeployedBy + lastExecutionStatus + lastExecutionTime + lastExecutionDuration + successfulExecutions + failedExecutions + executions(pageNumber: 1, pageSize: 10, order: { property: "startTime", direction: DESC }) { + id + status + logsUrl + } + } + } + totalPages + totalItems + } + }`, + { + pageNumber: 1, + pageSize: 1, + filter: [], + search: 'searchQueryValue' + } + ); + }); + }); + + describe('|getJobListState|', () => { + it('should verify will make expected calls', () => { + // Given + const apolloQueryResult: ApolloQueryResult = { + data: { + content: [{}], + totalItems: 1, + totalPages: 1 + }, + loading: false, + networkStatus: 7 + }; + dataJobsBaseServiceStub.getJobs.and.returnValue(of(apolloQueryResult)); + + // When + service.getJob('supercollider_demo', 'test-job-taur'); + + // Then + expect(dataJobsBaseServiceStub.getJobs).toHaveBeenCalledWith( + 'supercollider_demo', + `query jobsQuery($filter: [Predicate]) + { + jobs(pageNumber: 1, pageSize: 1, filter: $filter) { + content { + jobName + config { + team + description + sourceUrl + schedule { + scheduleCron + nextRunEpochSeconds + } + contacts { + notifiedOnJobSuccess + notifiedOnJobDeploy + notifiedOnJobFailureUserError + notifiedOnJobFailurePlatformError + } + } + deployments { + id + enabled + executions(pageNumber: 1, pageSize: 10, order: { property: "startTime", direction: DESC }) { + id + status + logsUrl + startedBy + startTime + endTime + } + } + } + totalPages + totalItems + } + }`, + { + filter: [ + { property: 'config.team', pattern: 'supercollider_demo', sort: null }, + { property: 'jobName', pattern: 'test-job-taur', sort: null } + ] + } + ); + }); + + it('should verify will return expected data for given response', (done) => { + // Given + const dataJob: DataJob = {}; + const apolloQueryResult1: ApolloQueryResult = undefined; + const apolloQueryResult2: ApolloQueryResult = { + data: undefined, + loading: false, + networkStatus: 7 + }; + const apolloQueryResult3: ApolloQueryResult = { + data: { + content: undefined + }, + loading: false, + networkStatus: 7 + }; + const apolloQueryResult4: ApolloQueryResult = { + data: { + content: [] + }, + loading: false, + networkStatus: 7 + }; + const apolloQueryResult5: ApolloQueryResult = { + data: { + content: [dataJob], + totalItems: 1, + totalPages: 1 + }, + loading: false, + networkStatus: 7 + }; + dataJobsBaseServiceStub.getJobs.and.returnValues( + of(apolloQueryResult1), + of(apolloQueryResult2), + of(apolloQueryResult3), + of(apolloQueryResult4), + of(apolloQueryResult5) + ); + + const executeNextCall = (executionId) => { + if (executionId === 4) { + service.getJob('supercollider_demo', 'test-job-taur') + .subscribe((value) => { + expect(value).toBe(dataJob); + done(); + }); + } else { + service.getJob('supercollider_demo', 'test-job-taur') + .subscribe((value) => { + expect(value).toBeNull(); + executeNextCall(++executionId); + }); + } + }; + + // When/Then + executeNextCall(0); + }); + }); + + describe('|getJobExecution|', () => { + it('should verify will make expected calls', () => { + // Given + const teamName = 'teamA'; + const jobName = 'jobA'; + const executionId = 'executionA'; + + // When + const response = service.getJobExecution(teamName, jobName, executionId); + + // Then + expect(response).toBeDefined(); + expect(httpClientStub.get).toHaveBeenCalledWith( + `/data-jobs/for-team/${ teamName }/jobs/${ jobName }/executions/${ executionId }` + ); + }); + }); + }); + + describe('validateModuleConfig', () => { + it('validates dataPipelinesModuleConfig with empty defaultOwnerTeamName', () => { + // @ts-ignore + expect(() => service._validateModuleConfig({ + defaultOwnerTeamName: '' + })).toThrow(new Error(MISSING_DEFAULT_TEAM_MESSAGE)); + }); + + it('validates dataPipelinesModuleConfig with reserved defaultOwnerTeamName', () => { + // @ts-ignore + expect(() => service._validateModuleConfig({ + defaultOwnerTeamName: 'default' + })).toThrow(new Error(RESERVED_DEFAULT_TEAM_NAME_MESSAGE)); + }); + }); + + describe('getJobDetails', () => { + it('returs observable', () => { + const jobDetailsObservable = service.getJobDetails('team001', 'job001'); + expect(jobDetailsObservable).toBeDefined(); + }); + }); + + describe('removeJob', () => { + it('returs observable', () => { + const removeJobObservable = service.removeJob('team001', 'job001'); + expect(removeJobObservable).toBeDefined(); + }); + }); + + describe('downloadFile', () => { + it('returs observable', () => { + const downloadFileObservable = service.downloadFile('team001', 'job001'); + expect(downloadFileObservable).toBeDefined(); + }); + }); + + describe('getJobDeployments', () => { + it('returs observable', () => { + const getJobDeploymentsObservable = service.getJobDeployments('team001', 'job001'); + expect(getJobDeploymentsObservable).toBeDefined(); + }); + }); + + describe('updateDataJobStatus', () => { + it('returs observable', () => { + const updateDataJobStatusObservable = service.updateDataJobStatus('team001', 'job001', 'deploy001', false); + expect(updateDataJobStatusObservable).toBeDefined(); + }); + }); + + describe('updateDataJobStatus', () => { + it('returs observable', () => { + const updateDataJobStatusObservable = service.updateDataJobStatus('team001', 'job001', null, false); + expect(updateDataJobStatusObservable).toBeDefined(); + }); + }); + + describe('updateDataJob', () => { + it('returs observable', () => { + const updateDataJobStatusObservable = service.updateDataJob('team001', 'job001', TEST_JOB_DETAILS); + expect(updateDataJobStatusObservable).toBeDefined(); + }); + }); + + describe('getJobExecutions', () => { + it('returs observable', () => { + const jobExecutionsObservable = service.getJobExecutions('team001', 'job001'); + expect(jobExecutionsObservable).toBeDefined(); + }); + }); + + describe('executeDataJob', () => { + it('returns observable', () => { + const teamName = 'team001'; + const jobName = 'job001'; + const deploymentId = 'hhw-dff-fgg-100'; + + const executeDataJobObservable = service.executeDataJob(teamName, jobName, deploymentId); + expect(executeDataJobObservable).toBeDefined(); + expect(httpClientStub.post) + .toHaveBeenCalledWith(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/deployments/${ deploymentId }/executions`, {}) + + }); + }); + + describe('cancelDataJob', () => { + it('returns observable', () => { + + const teamName = 'team001'; + const jobName = 'job001'; + const deploymentId = 'hhw-dff-fgg-100'; + const executionId = 'hhw-dff-fgg-100'; + + const executeDataJobObservable = service.executeDataJob(teamName, jobName, deploymentId); + expect(executeDataJobObservable).toBeDefined(); + expect(httpClientStub.post) + .toHaveBeenCalledWith(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/deployments/${ deploymentId }/executions`, {}) + + const cancelExecutionDataJobObservable = service.cancelDataJobExecution(teamName, jobName, executionId); + expect(cancelExecutionDataJobObservable).toBeDefined(); + expect(httpClientStub.delete) + .toHaveBeenCalledWith(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/executions/${ executionId }`) + + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.api.service.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.api.service.ts new file mode 100644 index 0000000000..143edb9a63 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.api.service.ts @@ -0,0 +1,331 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Inject, Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ApolloQueryResult } from '@apollo/client/core'; + +import { ApiPredicate, CollectionsUtil } from '@vdk/shared'; + +import { + DATA_PIPELINES_CONFIGS, + DataJob, + DataJobDeploymentDetails, + DataJobDetails, + DataJobExecutionDetails, + DataJobExecutionFilter, + DataJobExecutionOrder, + DataJobExecutionsPage, + DataJobPage, + DataPipelinesConfig +} from '../model'; + +import { DataJobsBaseApiService } from './data-jobs-base.api.service'; + +export const MISSING_DEFAULT_TEAM_MESSAGE = 'The defaultOwnerTeamName property need to be set for the DATA_PIPELINES_CONFIGS'; + +export const RESERVED_DEFAULT_TEAM_NAME_MESSAGE = `The 'default' value is reserved, and can not be used for defaultOwnerTeamName property`; + +@Injectable() +export class DataJobsApiService { + ownerTeamName: string; + + constructor( + @Inject(DATA_PIPELINES_CONFIGS) dataPipelinesModuleConfig: DataPipelinesConfig, + private readonly http: HttpClient, + private readonly dataJobsBaseService: DataJobsBaseApiService + ) { + this._validateModuleConfig(dataPipelinesModuleConfig); + + this.ownerTeamName = dataPipelinesModuleConfig?.defaultOwnerTeamName; + if (dataPipelinesModuleConfig?.ownerTeamNamesObservable) { + dataPipelinesModuleConfig.ownerTeamNamesObservable.subscribe((result: string[]) => { + if (result?.length) { + //Take the first element from the teams array + this.ownerTeamName = result[0]; + } + }); + } + } + + getJobs( + filters: ApiPredicate[], + searchQueryValue: string, + pageNumber: number, + pageSize: number): Observable> { + + return this.dataJobsBaseService.getJobs( + this.ownerTeamName, + `query jobsQuery($filter: [Predicate], $search: String, $pageNumber: Int, $pageSize: Int) + { + jobs(pageNumber: $pageNumber, pageSize: $pageSize, filter: $filter, search: $search) { + content { + jobName + config { + team + description + sourceUrl + schedule { + scheduleCron + nextRunEpochSeconds + } + contacts { + notifiedOnJobSuccess + notifiedOnJobDeploy + notifiedOnJobFailureUserError + notifiedOnJobFailurePlatformError + } + } + deployments { + id + enabled + lastDeployedDate + lastDeployedBy + lastExecutionStatus + lastExecutionTime + lastExecutionDuration + successfulExecutions + failedExecutions + executions(pageNumber: 1, pageSize: 10, order: { property: "startTime", direction: DESC }) { + id + status + logsUrl + } + } + } + totalPages + totalItems + } + }`, + { + pageNumber, + pageSize, + filter: filters, + search: searchQueryValue + } + ); + } + + getJob(teamName: string, jobName: string): Observable { + return this.dataJobsBaseService.getJobs( + teamName, + `query jobsQuery($filter: [Predicate]) + { + jobs(pageNumber: 1, pageSize: 1, filter: $filter) { + content { + jobName + config { + team + description + sourceUrl + schedule { + scheduleCron + nextRunEpochSeconds + } + contacts { + notifiedOnJobSuccess + notifiedOnJobDeploy + notifiedOnJobFailureUserError + notifiedOnJobFailurePlatformError + } + } + deployments { + id + enabled + executions(pageNumber: 1, pageSize: 10, order: { property: "startTime", direction: DESC }) { + id + status + logsUrl + startedBy + startTime + endTime + } + } + } + totalPages + totalItems + } + }`, + { + filter: this._createTeamJobNameFilter(teamName, jobName) + } + ).pipe( + map((response: ApolloQueryResult) => { + if (!CollectionsUtil.isArray(response?.data?.content) + || response.data.content.length === 0) { + + return null; + } + + return response.data.content[0]; + }) + ); + } + + getJobDetails(teamName: string, jobName: string): Observable { + return this.http.get(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }`); + } + + removeJob(teamName: string, jobName: string): Observable { + return this.http.delete(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }`); + } + + downloadFile(teamName: string, jobName: string): Observable { + const httpHeaders = new HttpHeaders(); + httpHeaders.append('Accept', 'application/octet-stream'); + + return this.http.get(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/keytab`, { + headers: httpHeaders, + responseType: 'blob' + }); + } + + getJobExecutions( + teamName: string, + jobName: string): Observable; + getJobExecutions( + teamName: string, + jobName: string, + forceGraphQL: boolean, + filter?: DataJobExecutionFilter, + order?: DataJobExecutionOrder, + pageNumber?: number, + pageSize?: number): Observable; + getJobExecutions( + teamName: string, + jobName: string, + forceGraphQL = false, + filter: DataJobExecutionFilter = null, + order: DataJobExecutionOrder = null, + pageNumber: number = null, + pageSize: number = null): Observable | Observable { + + if (!forceGraphQL) { + return this.http.get(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/executions`); + } + + const preparedFilter = { ...(filter ?? {}) }; + + if (jobName.length > 0) { + if (CollectionsUtil.isArray(preparedFilter.jobNameIn)) { + preparedFilter.jobNameIn.push(jobName); + } else { + preparedFilter.jobNameIn = [jobName]; + } + } + + return this.dataJobsBaseService.getExecutions( + teamName, + `query jobsQuery($pageNumber: Int, $pageSize: Int, $filter: DataJobExecutionFilter, $order: DataJobExecutionOrder) + { + executions(pageNumber: $pageNumber, pageSize: $pageSize, filter: $filter, order: $order) { + content { + id + type + jobName + status + startTime + endTime + startedBy + message + opId + logsUrl + deployment { + enabled + jobVersion + deployedDate + deployedBy + resources { + cpuLimit + cpuRequest + memoryLimit + memoryRequest + } + schedule { + scheduleCron + } + vdkVersion + status + } + } + totalPages + totalItems + } + }`, + { + pageNumber: pageNumber ?? 1, + pageSize: pageSize ?? 500, + filter: preparedFilter, + order: order ?? null + } + ).pipe( + map((response) => response.data) + ); + } + + getJobExecution(teamName: string, jobName: string, executionId: string): Observable { + return this.http.get(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/executions/${ executionId }`); + } + + getJobDeployments(teamName: string, jobName: string): Observable { + return this.http.get(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/deployments`); + } + + updateDataJobStatus( + teamName: string, + jobName: string, + deploymentId: string, + dataJobEnabled: boolean): Observable<{ enabled: boolean }> { + + const deploymentStatus = { enabled: dataJobEnabled }; + + if (!deploymentId) { + console.log(`Status update will be processed with default deploymentId`); + deploymentId = 'default'; + } + + return this.http.patch<{ enabled: boolean }>( + `/data-jobs/for-team/${ teamName }/jobs/${ jobName }/deployments/${ deploymentId }`, + deploymentStatus + ); + } + + updateDataJob( + teamName: string, + jobName: string, + dataJob: DataJobDetails + ): Observable { + return this.http.put(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }`, dataJob); + } + + executeDataJob(teamName: string, jobName: string, deploymentId: string): Observable { + return this.http.post(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/deployments/${ deploymentId }/executions`, {}); + } + + cancelDataJobExecution(teamName: string, jobName: string, executionId: string): Observable { + return this.http.delete(`/data-jobs/for-team/${ teamName }/jobs/${ jobName }/executions/${ executionId }`); + } + + private _createTeamJobNameFilter(teamName: string, jobName: string) { + return [ + { property: 'config.team', pattern: teamName, sort: null }, + { property: 'jobName', pattern: jobName, sort: null } + ]; + } + + private _validateModuleConfig(dataPipelinesModuleConfig: DataPipelinesConfig) { + if (!dataPipelinesModuleConfig?.defaultOwnerTeamName) { + throw new Error(MISSING_DEFAULT_TEAM_MESSAGE); + } + + if (dataPipelinesModuleConfig?.defaultOwnerTeamName === 'default') { + throw new Error(RESERVED_DEFAULT_TEAM_NAME_MESSAGE); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.service.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.service.spec.ts new file mode 100644 index 0000000000..e2d67c33f7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.service.spec.ts @@ -0,0 +1,221 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; + +import { Observable } from 'rxjs'; + +import { ComponentModel, ComponentService, ComponentStateImpl, RouterState, RouteState } from '@vdk/shared'; + +import { FETCH_DATA_JOB, FETCH_DATA_JOB_EXECUTIONS, FETCH_DATA_JOBS, UPDATE_DATA_JOB } from '../state/actions'; +import { TASK_LOAD_JOB_EXECUTIONS, TASK_UPDATE_JOB_DESCRIPTION } from '../state/tasks'; + +import { DataJobsService, DataJobsServiceImpl } from './data-jobs.service'; + +describe('DataJobsService -> DataJobsServiceImpl', () => { + let componentServiceStub: jasmine.SpyObj; + + let service: DataJobsService; + + beforeEach(() => { + componentServiceStub = jasmine.createSpyObj('componentService', [ + 'load', + 'dispatchAction' + ]); + + TestBed.configureTestingModule({ + providers: [ + { provide: ComponentService, useValue: componentServiceStub }, + { provide: DataJobsService, useClass: DataJobsServiceImpl } + ] + }); + + service = TestBed.inject(DataJobsService); + }); + + it('should verify instance is created', () => { + // Then + expect(service).toBeDefined(); + }); + + describe('Methods::', () => { + describe('|loadJob|', () => { + it('should verify will invoke expected method', () => { + // Given + const model = ComponentModel.of( + ComponentStateImpl.of({ + id: 'test-component' + }), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + + // When + service.loadJob(model); + + // Then + expect(componentServiceStub.load).toHaveBeenCalledWith(model.getComponentState()); + expect(componentServiceStub.dispatchAction).toHaveBeenCalledWith(FETCH_DATA_JOB, model.getComponentState()); + }); + }); + + describe('|loadJobs|', () => { + it('should verify will invoke expected method', () => { + // Given + const model = ComponentModel.of( + ComponentStateImpl.of({ + id: 'test-component' + }), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + + // When + service.loadJobs(model); + + // Then + expect(componentServiceStub.load).toHaveBeenCalledWith(model.getComponentState()); + expect(componentServiceStub.dispatchAction).toHaveBeenCalledWith(FETCH_DATA_JOBS, model.getComponentState()); + }); + }); + + describe('|loadJobExecutions|', () => { + it('should verify will invoke expected method', () => { + // Given + const model = ComponentModel.of( + ComponentStateImpl.of({ + id: 'test-component' + }), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + + // When + service.loadJobExecutions(model); + + // Then + expect(componentServiceStub.load).toHaveBeenCalledWith(model.getComponentState()); + expect(componentServiceStub.dispatchAction) + .toHaveBeenCalledWith(FETCH_DATA_JOB_EXECUTIONS, model.getComponentState(), TASK_LOAD_JOB_EXECUTIONS); + }); + }); + + describe('|updateJob|', () => { + it('should verify will invoke expected method', () => { + // Given + const model = ComponentModel.of( + ComponentStateImpl.of({ + id: 'test-component' + }), + RouterState.of( + RouteState.empty(), + 1 + ) + ); + + // When + service.updateJob(model, TASK_UPDATE_JOB_DESCRIPTION); + + // Then + expect(componentServiceStub.load).toHaveBeenCalledWith(model.getComponentState()); + expect(componentServiceStub.dispatchAction) + .toHaveBeenCalledWith(UPDATE_DATA_JOB, model.getComponentState(), TASK_UPDATE_JOB_DESCRIPTION); + }); + }); + + describe('|getNotifiedForRunningJobExecutionId|', () => { + it('should verify will return Observable', () => { + // Given + // eslint-disable-next-line @typescript-eslint/dot-notation + const asObservableSpy = spyOn((service as DataJobsServiceImpl)['_runningJobExecutionId'], 'asObservable').and.callThrough(); + + // When + const observable = service.getNotifiedForRunningJobExecutionId(); + + // Then + expect(observable).toBeInstanceOf(Observable); + expect(asObservableSpy).toHaveBeenCalled(); + }); + }); + + describe('|notifyForRunningJobExecutionId|', () => { + it('should verify will notify correct Subject', () => { + // Given + // eslint-disable-next-line @typescript-eslint/dot-notation + const nextSpy = spyOn((service as DataJobsServiceImpl)['_runningJobExecutionId'], 'next').and.callThrough(); + + // When + service.notifyForRunningJobExecutionId('xy'); + + // Then + expect(nextSpy).toHaveBeenCalledWith('xy'); + }); + }); + + describe('|getNotifiedForJobExecutions|', () => { + it('should verify will return Observable', () => { + // Given + // eslint-disable-next-line @typescript-eslint/dot-notation + const asObservableSpy = spyOn((service as DataJobsServiceImpl)['_jobExecutions'], 'asObservable').and.callThrough(); + + // When + const observable = service.getNotifiedForJobExecutions(); + + // Then + expect(observable).toBeInstanceOf(Observable); + expect(asObservableSpy).toHaveBeenCalled(); + }); + }); + + describe('|notifyForJobExecutions|', () => { + it('should verify will notify correct Subject', () => { + // Given + // eslint-disable-next-line @typescript-eslint/dot-notation + const nextSpy = spyOn((service as DataJobsServiceImpl)['_jobExecutions'], 'next').and.callThrough(); + + // When + service.notifyForJobExecutions([null]); + + // Then + expect(nextSpy).toHaveBeenCalledWith([null]); + }); + }); + + describe('|getNotifiedForTeamImplicitly|', () => { + it('should verify will return Observable', () => { + // Given + // eslint-disable-next-line @typescript-eslint/dot-notation + const asObservableSpy = spyOn((service as DataJobsServiceImpl)['_implicitTeam'], 'asObservable').and.callThrough(); + + // When + const observable = service.getNotifiedForTeamImplicitly(); + + // Then + expect(observable).toBeInstanceOf(Observable); + expect(asObservableSpy).toHaveBeenCalled(); + }); + }); + + describe('|notifyForTeamImplicitly|', () => { + it('should verify will notify correct BehaviorSubject', () => { + // Given + // eslint-disable-next-line @typescript-eslint/dot-notation + const nextSpy = spyOn((service as DataJobsServiceImpl)['_implicitTeam'], 'next').and.callThrough(); + + // When + service.notifyForTeamImplicitly('teamZero'); + + // Then + expect(nextSpy).toHaveBeenCalledWith('teamZero'); + }); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.service.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.service.ts new file mode 100644 index 0000000000..2b54581bd0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/data-jobs.service.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable, Subject } from 'rxjs'; + +import { ComponentModel, ComponentService } from '@vdk/shared'; + +import { FETCH_DATA_JOB, FETCH_DATA_JOB_EXECUTIONS, FETCH_DATA_JOBS, UPDATE_DATA_JOB } from '../state/actions'; +import { DataJobUpdateTasks, TASK_LOAD_JOB_EXECUTIONS } from '../state/tasks'; + +import { DataJobExecutions } from '../model'; + +export abstract class DataJobsService { + /** + * ** Trigger Action for loading DataJobs data. + */ + abstract loadJobs(model: ComponentModel): void; + + /** + * ** Trigger Actions to load all necessary data for Data Job. + */ + abstract loadJob(model: ComponentModel): void; + + /** + * ** Trigger Action for loading Data Job executions data. + */ + abstract loadJobExecutions(model: ComponentModel): void; + + /** + * ** Trigger Action update Job. + */ + abstract updateJob(model: ComponentModel, task: DataJobUpdateTasks): void; + + /** + * ** Returns Observable(Subject) that fires when Running Job Execution ID change. + */ + abstract getNotifiedForRunningJobExecutionId(): Observable; + + /** + * ** Send new event to Observable stream. + */ + abstract notifyForRunningJobExecutionId(id: string): void; + + /** + * ** Returns Observable(Subject) that fires with new Job Executions. + */ + abstract getNotifiedForJobExecutions(): Observable; + + /** + * ** Send new event to Observable stream. + */ + abstract notifyForJobExecutions(executions: DataJobExecutions): void; + + /** + * ** Returns Observable(BehaviorSubject) that fires with team name implicitly. + */ + abstract getNotifiedForTeamImplicitly(): Observable; + + /** + * ** Send new event to Observable stream. + */ + abstract notifyForTeamImplicitly(team: string): void; +} + +@Injectable() +export class DataJobsServiceImpl extends DataJobsService { + private readonly _runningJobExecutionId: Subject; + private readonly _jobExecutions: Subject; + private readonly _implicitTeam: BehaviorSubject; + + /** + * ** Constructor. + */ + constructor(private readonly componentService: ComponentService) { + super(); + + this._runningJobExecutionId = new Subject(); + this._jobExecutions = new Subject(); + this._implicitTeam = new BehaviorSubject(undefined); + } + + /** + * @inheritDoc + */ + loadJobs(model: ComponentModel): void { + this.componentService.load(model.getComponentState()); + this.componentService.dispatchAction(FETCH_DATA_JOBS, model.getComponentState()); + } + + loadJob(model: ComponentModel): void { + this.componentService.load(model.getComponentState()); + this.componentService.dispatchAction(FETCH_DATA_JOB, model.getComponentState()); + } + + /** + * @inheritDoc + */ + loadJobExecutions(model: ComponentModel): void { + this.componentService.load(model.getComponentState()); + this.componentService.dispatchAction(FETCH_DATA_JOB_EXECUTIONS, model.getComponentState(), TASK_LOAD_JOB_EXECUTIONS); + } + + /** + * @inheritDoc + */ + updateJob(model: ComponentModel, task: DataJobUpdateTasks): void { + this.componentService.load(model.getComponentState()); + this.componentService.dispatchAction(UPDATE_DATA_JOB, model.getComponentState(), task); + } + + /** + * @inheritDoc + */ + getNotifiedForJobExecutions(): Observable { + return this._jobExecutions.asObservable(); + } + + /** + * @inheritDoc + */ + notifyForJobExecutions(executions: DataJobExecutions): void { + this._jobExecutions.next(executions); + } + + /** + * @inheritDoc + */ + getNotifiedForRunningJobExecutionId(): Observable { + return this._runningJobExecutionId.asObservable(); + } + + /** + * @inheritDoc + */ + notifyForRunningJobExecutionId(id: string): void { + this._runningJobExecutionId.next(id); + } + + /** + * @inheritDoc + */ + getNotifiedForTeamImplicitly(): Observable { + return this._implicitTeam.asObservable(); + } + + /** + * @inheritDoc + */ + notifyForTeamImplicitly(team: string): void { + this._implicitTeam.next(team); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/index.ts new file mode 100644 index 0000000000..8eab64d46e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-base.api.service'; +export * from './data-jobs-public.api.service'; +export * from './data-jobs.api.service'; +export * from './data-jobs.service'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/public-api.ts new file mode 100644 index 0000000000..8d01600a5a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/services/public-api.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs-public.api.service'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.html new file mode 100644 index 0000000000..cd5aa5605f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.html @@ -0,0 +1,24 @@ + + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.scss new file mode 100644 index 0000000000..66cac016db --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.scss @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.schema-confirm-to-prod { + .modal-title { + margin-bottom: .5rem; + } + + .signpostBtn { + height: 0rem; + + .btn-info { + margin-top: -.65rem; + border: 0; + height: 1.2rem; + } + } + +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.spec.ts new file mode 100644 index 0000000000..74a4b86d39 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.spec.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ConfirmationDialogModalComponent } from './confirmation-dialog-modal.component'; + +describe('ConfirmationDialogModalComponent', () => { + let component: ConfirmationDialogModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + ConfirmationDialogModalComponent + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfirmationDialogModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('confirm', () => { + it('makes expected calls', () => { + spyOn(component.changeStatus, 'emit').and.callThrough(); + component.confirm(); + expect(component.changeStatus.emit).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.ts new file mode 100644 index 0000000000..14878bdcdc --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/confirmation-dialog-modal.component.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ConfirmationModalOptions } from '../../model/modal-options'; +import { ModalComponentDirective } from '../modal/modal.component'; + +@Component({ + selector: 'lib-confirmation-dialog-modal', + templateUrl: './confirmation-dialog-modal.component.html', + styleUrls: ['./confirmation-dialog-modal.component.scss'] +}) +export class ConfirmationDialogModalComponent extends ModalComponentDirective { + + @Input() confirmationInput: string; + @Output() changeStatus: EventEmitter = new EventEmitter(); + + constructor() { + super(); + this.options = new ConfirmationModalOptions(); + } + + override confirm(): void { + super.confirm(); + + this.changeStatus.emit(); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/index.ts new file mode 100644 index 0000000000..ce1f69f722 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/confirmation-dialog-modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './confirmation-dialog-modal.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.html new file mode 100644 index 0000000000..f0830bc11b --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.scss new file mode 100644 index 0000000000..9a1a8a5ae5 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.scss @@ -0,0 +1,14 @@ + +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.datagrid-filter { + outline: none; + + .close { + outline: none; + box-shadow: none; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.spec.ts new file mode 100644 index 0000000000..2ed9fc194b --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.spec.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ClrDatagridFilter } from '@clr/angular'; + +import { ColumnFilterComponent } from './column-filter.component'; + +describe('ColumnFilterComponent', () => { + let component: ColumnFilterComponent; + let fixture: ComponentFixture; + + const TEST_VALUE = 'test_value'; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ColumnFilterComponent], + providers: [{ + provide: ClrDatagridFilter, + useFactory: () => ({ + setFilter: () => ({}) + }) + }] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ColumnFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('toggle selection', () => { + it('change value', () => { + component.toggleSelection({ target: ({ value: TEST_VALUE } as unknown) as EventTarget } as Event); + expect(component.value).toBe(TEST_VALUE); + }); + }); + + describe('clean filter', () => { + it('remove value', () => { + component.cleanFilter(); + expect(component.value).toBe(null); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.ts new file mode 100644 index 0000000000..c44b459396 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/column-filter.component.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, TemplateRef, ViewEncapsulation } from '@angular/core'; + +import { Observable, Subject } from 'rxjs'; + +import { ClrDatagridFilter, ClrDatagridFilterInterface } from '@clr/angular'; + +import { DataJob } from '../../../../model'; + +@Component({ + selector: 'lib-column-filter', + templateUrl: './column-filter.component.html', + styleUrls: ['./column-filter.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ColumnFilterComponent implements ClrDatagridFilterInterface, OnChanges { + + @Input() property: string; + @Input() listOfOptions: string[]; + @Input() isExecutionStatus = false; + + @Input() optionRenderer: TemplateRef = null; + + @Input() value: string; + @Output() valueChange = new EventEmitter(); + + // We do not want to expose the Subject itself, but the Observable which is read-only + get changes(): Observable { + return this._changesSubject.asObservable(); + } + + private _changesSubject = new Subject(); + + constructor(private filterContainer: ClrDatagridFilter) { + filterContainer.setFilter(this); + } + + isActive(): boolean { + return !!this.value; + } + + accepts(_item: DataJob): boolean { + return true; + } + + toggleSelection($event: Event) { + this._setValue(($event.target as HTMLInputElement).value); + } + + cleanFilter() { + this._setValue(null); + } + + isValueSelected(value: string) { + return this.value === value; + } + + /** + * @inheritDoc + */ + ngOnChanges(changes: SimpleChanges): void { + if (changes['value'].firstChange) { + return; + } + + this._changesSubject.next(changes['value'].currentValue as string); + } + + private _setValue(value: string): void { + this.value = value; + this.valueChange.emit(this.value); + this._changesSubject.next(this.value); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/index.ts new file mode 100644 index 0000000000..5aafec3da1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/column-filter/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './column-filter.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.html new file mode 100644 index 0000000000..389fbea63e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.html @@ -0,0 +1,84 @@ + + +
+
+ + + + + + + + + + + +
+ +
+ + + +
+ +
+ + + +
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.scss new file mode 100644 index 0000000000..bbecc07688 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.scss @@ -0,0 +1,88 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.grid-actions-container { + display: flex; + justify-content: space-between; + align-content: center; + flex-direction: row; + margin-top: 20px; + min-height: 1.5rem; + + .btn-actions-container { + .btn { + margin: .25rem 0; + padding: 0 .5rem; + } + } + + > .btn-actions-container { + button { + &:first-child { + padding-left: 0; + } + } + } + + .btn-filters-container { + display: flex; + flex-grow: 1; + justify-content: flex-end; + } + + .btn-search-container { + display: inline-flex; + + .search-container { + display: inline-flex; + margin: 0 !important; + transform: translateY(0.15rem); + + input { + &.clr-input { + padding-top: 2px; + } + } + } + } + + .tooltip-content { + text-transform: initial; + font-size: 11px !important; + } + + > * { + margin-right: 1rem; + + &:last-child { + margin-right: 0; + } + } +} + +.custom-buttons { + .btn { + &:not(.custom-btn) { + margin: initial !important; + padding: initial !important; + } + } +} + +.btn { + &.btn-link { + &.btn-link-red { + color: #e62700; + + &:hover { + color: #a32100; + } + + &:disabled { + color: #565656; + } + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.spec.ts new file mode 100644 index 0000000000..01367abc2e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.spec.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { GridActionComponent } from './grid-action.component'; + +describe('GridActionComponent', () => { + let component: GridActionComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [GridActionComponent] + }); + fixture = TestBed.createComponent(GridActionComponent); + component = fixture.componentInstance; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + it(`id has default value`, () => { + expect(component.id).toBeDefined(); + }); + + it(`addId has default value`, () => { + expect(component.addId).toBeDefined(); + }); + + it(`editId has default value`, () => { + expect(component.editId).toBeDefined(); + }); + + it(`removeId has default value`, () => { + expect(component.removeId).toBeDefined(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.ts new file mode 100644 index 0000000000..84d7f63cb5 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/grid-action.component.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewEncapsulation } from '@angular/core'; + +import { CollectionsUtil } from '@vdk/shared'; + +import { QuickFilterChangeEvent, QuickFilters } from '../../quick-filters'; + +@Component({ + selector: 'lib-grid-action', + templateUrl: './grid-action.component.html', + styleUrls: ['./grid-action.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class GridActionComponent implements AfterViewInit, OnChanges { + + @Input() id = 'lib-ga-search-id'; + @Input() addId = 'lib-ga-add-id'; + @Input() editId = 'lib-ga-edit-id'; + @Input() removeId = 'lib-ga-remove-id'; + + @Input() addLabel: string; + @Input() editLabel: string; + @Input() removeLabel: string; + + @Input() addTooltip: string; + @Input() editTooltip: string; + @Input() removeTooltip: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + @Input() selectedValue: any | any[]; + @Input() searchQueryValue = ''; + + @Input() disableAdd: boolean; + @Input() disableEdit: boolean; + @Input() disableRemove: boolean; + + /** + * ** Proxy config for QuickFilters component. + */ + @Input() quickFilters: QuickFilters; + @Input() suppressQuickFilterChangeEvent: boolean; + + /** + * ** Proxy emitter from QuickFilters component. + */ + @Output() quickFilterChange = new EventEmitter(); + + @Output() search: EventEmitter = new EventEmitter(); + @Output() add: EventEmitter = new EventEmitter(); + /* eslint-disable @typescript-eslint/no-explicit-any */ + @Output() edit: EventEmitter = new EventEmitter(); + @Output() remove: EventEmitter = new EventEmitter(); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + queryValue: string; + + ngAfterViewInit(): void { + this.setQueryValue(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['searchQueryValue']) { + this.setQueryValue(); + } + } + + get editDisabled(): boolean { + return CollectionsUtil.isNil(this.selectedValue) || + (CollectionsUtil.isString(this.selectedValue) && this.selectedValue.length === 0) || + this.disableEdit; + } + + get addDisabled(): boolean { + return this.disableAdd; + } + + get removeDisabled(): boolean { + return CollectionsUtil.isNil(this.selectedValue) || + (CollectionsUtil.isString(this.selectedValue) && this.selectedValue.length === 0) || + this.disableRemove; + } + + /** + * vmw-search is being broken for one-way binding related to an input [searchQueryValue] + * this fix is a workaround (adding a delay of 1 milisecond to set queryValue, looks like + * needs to run in a separate thread) + */ + private setQueryValue() { + setTimeout(() => { + this.queryValue = this.searchQueryValue; + }, 1); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/index.ts new file mode 100644 index 0000000000..cf678b24de --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/grid-action/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './grid-action.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/index.ts new file mode 100644 index 0000000000..b91e17af7f --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/data-grid/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './grid-action'; +export * from './column-filter'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.css b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.css new file mode 100644 index 0000000000..4bc0214798 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.css @@ -0,0 +1,6 @@ +/* + * Copyright 2023-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* No-op. */ diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.html new file mode 100644 index 0000000000..00e2a1c087 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.html @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.spec.ts new file mode 100644 index 0000000000..be6b5a4e90 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.spec.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DeleteModalComponent } from './delete-modal.component'; + +describe('DeleteModalComponent', () => { + let component: DeleteModalComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [DeleteModalComponent] + }); + fixture = TestBed.createComponent(DeleteModalComponent); + component = fixture.componentInstance; + }); + + it('can load instance', () => { + expect(component).toBeTruthy(); + }); + + describe('confirm', () => { + it('makes expected calls', () => { + spyOn(component, 'close').and.callThrough(); + component.confirm(); + expect(component.close).toHaveBeenCalled(); + }); + }); + + describe('cancel', () => { + it('makes expected calls', () => { + spyOn(component, 'close').and.callThrough(); + component.cancel(); + expect(component.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.ts new file mode 100644 index 0000000000..0eb741b6f3 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/delete-modal.component.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Output } from '@angular/core'; + +import { DeleteModalOptions } from '../../model'; + +import { ModalComponentDirective } from '../modal'; + +@Component({ + selector: 'lib-delete-modal', + templateUrl: './delete-modal.component.html', + styleUrls: ['./delete-modal.component.css'] +}) +export class DeleteModalComponent extends ModalComponentDirective { + + @Output() delete: EventEmitter = new EventEmitter(); + + constructor() { + super(); + this.options = new DeleteModalOptions(); + } + + /** + * emit that the user confirmed that it want to delete the item + * and close the modal + */ + override confirm(): void { + super.confirm(); + + this.delete.emit(); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/index.ts new file mode 100644 index 0000000000..c183587f78 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/delete-modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './delete-modal.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.html new file mode 100644 index 0000000000..a89d7574a7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.html @@ -0,0 +1,31 @@ + + + + +
+ + + + + + + + +
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.scss new file mode 100644 index 0000000000..0129a49763 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.scss @@ -0,0 +1,23 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.empty-state-container { + + vdk-empty-state-placeholder { + text-align: center; + ::ng-deep h2 { + font-size: 18px !important; + font-weight: 500; + margin-top: .1rem !important; + line-height: 1rem; + } + + ::ng-deep h3 { + font-size: 13px !important; + line-height: 1rem; + margin-top: .3rem; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.spec.ts new file mode 100644 index 0000000000..e5d1a45e05 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * /* eslint-disable @typescript-eslint/no-unused-vars + * + * @format + */ + +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { EmptyStateComponent } from './empty-state.component'; + +describe('EmptyStateComponent', () => { + let component: EmptyStateComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [EmptyStateComponent] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(EmptyStateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.ts new file mode 100644 index 0000000000..0582212286 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/empty-state.component.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @format */ + +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'lib-empty-state', + templateUrl: './empty-state.component.html', + styleUrls: ['./empty-state.component.scss'] +}) +export class EmptyStateComponent { + @Input() title = 'Empty State'; + @Input() description = 'Description'; + @Input() width = 256; + @Input() imgSrc: string; + @Input() hideImage = false; + @Input() opacity = 1; + @Input() animSrc = 'assets/animations/no-events-in-timeframe-animation.json'; + @Input() marginTop = '3rem'; + @Input() marginBottom = '20px'; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/index.ts new file mode 100644 index 0000000000..d28b92b250 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/empty-state/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './empty-state.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.html new file mode 100644 index 0000000000..dce978e2a3 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.html @@ -0,0 +1,100 @@ + + + + +
    +
  • + +
    + + + Executing... + + + + + + + + +
    + + {{(execution.status ? execution.status.replace('_', ' ') : execution.status) | titlecase}} + +
    +
    + Duration: + {{execution | formatDelta}} +
    + + + + Manual +
    +
    +
  • + + +
  • +
    + + + +
    + Scheduled + {{ next | date: 'MMM d, y, hh:mm a':'UTC' }} UTC +
    +
  • +
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.scss new file mode 100644 index 0000000000..d32c614eff --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.scss @@ -0,0 +1,48 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.clr-timeline-step clr-icon[shape=success-standard] { + color: var(--clr-timeline-success-step-color, #5eb715); +} + +.clr-timeline-step clr-icon[shape=error-standard] { + color: var(--clr-timeline-error-step-color, #c21d00); +} + +.clr-timeline-step clr-icon { + height: 1.8rem; + width: 1.8rem; + min-height: 1.8rem; + min-width: 1.8rem; +} + +.clr-timeline-horizontal { + padding-top: 35px; +} + +.manual-execution-label { + margin-top: 5px; +} + +.clr-timeline { + .clr-timeline__step-header--underline-dotted { + text-decoration: underline; + text-decoration-style: dotted; + } + + .clr-timeline__log-link { + min-height: 0 !important; + min-width: 0 !important; + } + + .clr-timeline__element--display-block { + display: block; + } + + .clr-timeline__duration-tag { + font-weight: bold; + text-decoration: underline; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.spec.ts new file mode 100644 index 0000000000..ca2105a681 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.spec.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExecutionsTimelineComponent } from './executions-timeline.component'; + +import { DATA_PIPELINES_CONFIGS, DataJobExecution, DataJobExecutionType } from '../../../model'; + +describe('ExecutionsTimelineComponent', () => { + let component: ExecutionsTimelineComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ExecutionsTimelineComponent], + providers: [ + { + provide: DATA_PIPELINES_CONFIGS, + useFactory: () => ({ + defaultOwnerTeamName: 'all', + manageConfig: { + allowKeyTabDownloads: true, + allowExecuteNow: true + }, + healthStatusUrl: 'baseUrl' + }) + } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ExecutionsTimelineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create', () => { + const mockExec = { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + startedBy: 'manual/auser' + } as DataJobExecution; + expect(component.getManualExecutedByTitle(mockExec)).toBe(ExecutionsTimelineComponent.manualRunKnownUser + ' auser'); + + mockExec.startedBy = 'manual/manual'; + mockExec.type = DataJobExecutionType.MANUAL; + expect(component.getManualExecutedByTitle(mockExec)).toBe(ExecutionsTimelineComponent.manualRunKnownUser + ' manual'); + + mockExec.startedBy = 'scheduled/runtime'; + mockExec.type = DataJobExecutionType.SCHEDULED; + expect(component.getManualExecutedByTitle(mockExec)).toContain(ExecutionsTimelineComponent.manualRunNoUser); + + mockExec.startedBy = 'scheduled/runtime'; + mockExec.type = null; + expect(component.getManualExecutedByTitle(mockExec)).toContain(ExecutionsTimelineComponent.manualRunNoUser); + }); + + describe('Methods::', () => { + // TODO write some unit tests + it('no-op', () => { + // No-op. + expect(true).toBeTrue(); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.ts new file mode 100644 index 0000000000..779a69b378 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/executions-timeline.component.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core'; + +import { + DATA_PIPELINES_CONFIGS, + DataJobExecution, + DataJobExecutions, + DataJobExecutionStatus, + DataJobExecutionType, + DataPipelinesConfig +} from '../../../model'; + +@Component({ + selector: 'lib-executions-timeline', + templateUrl: './executions-timeline.component.html', + styleUrls: ['./executions-timeline.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ExecutionsTimelineComponent { + static manualRunKnownUser = 'This job is triggered manually by user'; + static manualRunNoUser = 'This job is triggered manually, but there is no info about the user'; + + @Input() jobExecutions: DataJobExecutions = []; + @Input() next: Date = null; + + dataJobExecutionStatus = DataJobExecutionStatus; + + constructor(@Inject(DATA_PIPELINES_CONFIGS) public dataPipelinesModuleConfig: DataPipelinesConfig) { + } + + /** + * ** NgFor elements tracking function. + */ + trackByFn(index: number, execution: DataJobExecution): string { + return `${ index }|${ execution.id }`; + } + + isExecutionManual(execution: DataJobExecution): boolean { + return execution?.type === DataJobExecutionType.MANUAL; + } + + getManualExecutedByTitle(execution: DataJobExecution): string { + if (!execution || !execution.startedBy || !execution.startedBy.startsWith('manual/')) { + // execution has no info abot user provided + return ExecutionsTimelineComponent.manualRunNoUser; + } + + const user = execution.startedBy.replace('manual/', ''); + + return `${ ExecutionsTimelineComponent.manualRunKnownUser } ${ user }`; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/index.ts new file mode 100644 index 0000000000..00900e3fce --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/executions-timeline/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './executions-timeline.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/index.ts new file mode 100644 index 0000000000..d617005ca3 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './confirmation-dialog-modal'; +export * from './delete-modal'; +export * from './executions-timeline'; +export * from './data-grid'; +export * from './modal'; +export * from './quick-filters'; +export * from './status'; +export * from './widget-value'; +export * from './empty-state'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/modal/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/modal/index.ts new file mode 100644 index 0000000000..703e041d67 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './modal.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/modal/modal.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/modal/modal.component.ts new file mode 100644 index 0000000000..7b0b9dd746 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/modal/modal.component.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Directive, EventEmitter, Input, Output } from '@angular/core'; + +import { TaurusObject } from '@vdk/shared'; + +import { ModalOptions } from '../../model'; + +@Directive() +export abstract class ModalComponentDirective extends TaurusObject { + @Input() options: ModalOptions; + + @Output() optionsChange: EventEmitter = new EventEmitter(); + + @Output() cancelAction: EventEmitter = new EventEmitter(); + + constructor() { + super(); + } + + confirm() { + this.close(); + } + + /** + * close the modal + */ + close(): void { + if (!this._isNull(this.options)) { + this.options.opened = false; + this.optionsChange.emit(this.options); + } + } + + cancel() { + this.cancelAction.emit(); + this.close(); + } + + private _isNull(value: ModalOptions): boolean { + return value === null || value === undefined; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/index.ts new file mode 100644 index 0000000000..4402c64bd4 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './quick-filters.component'; +export * from './model'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/model/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/model/index.ts new file mode 100644 index 0000000000..5d0c12bf83 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/model/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './quick-filters.model'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/model/quick-filters.model.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/model/quick-filters.model.ts new file mode 100644 index 0000000000..65a029a294 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/model/quick-filters.model.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Attributes } from '../../../directives'; + +interface IconAttributes extends Attributes { + style?: string; + title?: string; + class?: string; + shape?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-cy'?: string; + size?: number; +} + +export interface QuickFilter { + id?: string; + label: string; + icon?: IconAttributes; + active?: boolean; + suppressCancel?: boolean; + onActivate?: () => void; + onDeactivate?: () => void; +} + +export type QuickFilters = QuickFilter[]; + +export interface QuickFilterChangeEvent { + deactivatedFilter: QuickFilter | null; + activatedFilter: QuickFilter | null; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.html new file mode 100644 index 0000000000..13ef2940d8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.html @@ -0,0 +1,34 @@ + + +
+ +
+ QUICK FILTERS: +
+ +
+ + + + + + {{filter.label}} + + +
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.scss b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.scss new file mode 100644 index 0000000000..33c00741ac --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.scss @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +:host { + display: flex; + align-items: center; +} + +.quick-filters { + display: flex; + + .quick-filters__title { + display: inline-flex; + font-size: var(--clr-btn-appearance-standard-font-size, 0.55rem); + text-transform: uppercase; + align-items: center; + margin-right: 0.25rem; + white-space: nowrap; + } + + .quick-filters__list { + display: inline-flex; + align-items: center; + + .quick-filters__list-item { + margin: 0 0 0 0.3rem; + cursor: pointer; + } + + .label-light-blue { + border: 1px solid #4474b9; + background-color: rgb(177, 208, 255); + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.spec.ts new file mode 100644 index 0000000000..1ffed7d1e0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.spec.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QuickFiltersComponent } from './quick-filters.component'; + +describe('QuickFiltersComponent', () => { + let component: QuickFiltersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [QuickFiltersComponent] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QuickFiltersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.ts new file mode 100644 index 0000000000..55fc3cc197 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/quick-filters/quick-filters.component.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; + +import { CollectionsUtil } from '@vdk/shared'; + +import { QuickFilter, QuickFilterChangeEvent, QuickFilters } from './model'; + +@Component({ + selector: 'lib-quick-filters', + templateUrl: './quick-filters.component.html', + styleUrls: ['./quick-filters.component.scss'] +}) +export class QuickFiltersComponent implements OnChanges { + /** + * ** Quick Filters array config. + */ + @Input() set quickFilters(filters: QuickFilters) { + this._quickFilters = CollectionsUtil.isArray(filters) + ? filters + : []; + } + + get quickFilters(): QuickFilters { + return this._quickFilters; + } + + /** + * ** Show or hide Label "QUICK FILTERS" before filters list. + * + * - true - Show + * - false - Hide + */ + @Input() showFiltersLabel = false; + + /** + * ** Suppress emitted event when some filter state change. + * + * - true - Event wont be emitted + * - false - Event would be emitted on change + */ + @Input() suppressQuickFilterChangeEvent = false; + + /** + * ** Event Emitter for Filter state change. + */ + @Output() quickFilterChange = new EventEmitter(); + + activatedFilter: QuickFilter; + + private _quickFilters: QuickFilters = []; + private _deactivatedFilter: QuickFilter | null = null; + + /** + * ** NgFor elements tracking function. + */ + trackByFn(index: number, filter: QuickFilter): string { + return `${ index }|${ filter.id }`; + } + + /** + * ** Executed when some filter change it's state. + *

+ * State changes when User click on some Filter or press Enter while it's on focus. + *

+ */ + changeFilter(filter: QuickFilter): void { + const executeOnDeactivate = (dFilter: QuickFilter) => { + if (this.suppressQuickFilterChangeEvent + && CollectionsUtil.isDefined(dFilter) + && CollectionsUtil.isFunction(dFilter.onDeactivate)) { + + dFilter.onDeactivate(); + } + }; + + if (this.activatedFilter === filter) { + if (!filter.suppressCancel) { + this._deactivatedFilter = this.activatedFilter; + this.activatedFilter = null; + executeOnDeactivate(this._deactivatedFilter); + } + } else { + this._deactivatedFilter = this.activatedFilter; + this.activatedFilter = filter; + executeOnDeactivate(this._deactivatedFilter); + } + + if (this.suppressQuickFilterChangeEvent) { + if (CollectionsUtil.isDefined(this.activatedFilter) + && CollectionsUtil.isFunction(this.activatedFilter.onActivate)) { + + this.activatedFilter.onActivate(); + } else { + console.warn('QuickFiltersComponent: No listener for onActivate callback while Event Emitter is suppressed.'); + } + } else { + this.quickFilterChange.emit({ + activatedFilter: this.activatedFilter, + deactivatedFilter: this._deactivatedFilter + }); + } + } + + /** + * @inheritDoc + */ + ngOnChanges(changes: SimpleChanges) { + if (changes['quickFilters']) { + if (!changes['quickFilters'].firstChange) { + return; + } + + const defaultActiveFilter = this.quickFilters.find((f) => f.active); + + if (CollectionsUtil.isDefined(defaultActiveFilter)) { + this.activatedFilter = defaultActiveFilter; + } + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/index.ts new file mode 100644 index 0000000000..9fdb446da7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './status-panel/status-panel.component'; +export * from './status-cell/status-cell.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.css b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.css new file mode 100644 index 0000000000..b2329ad63e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.css @@ -0,0 +1,12 @@ +/* + * Copyright 2023-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.status-icon-enabled { + color: hsl(93, 67%, 38%); +} + +.status-icon-disabled { + color: hsl(32, 95%, 48%); +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.html new file mode 100644 index 0000000000..258cc99ef4 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.html @@ -0,0 +1,29 @@ + + + + + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.spec.ts new file mode 100644 index 0000000000..631a6fa608 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.spec.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ExtractJobStatusPipe } from '../../../pipes/extract-job-status.pipe'; + +import { StatusCellComponent } from './status-cell.component'; + +const TEST_JOB = { + jobName: 'job002', + config: { + description: 'description002' + } +}; + +describe('StatusCellComponent', () => { + let component: StatusCellComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + StatusCellComponent, + ExtractJobStatusPipe + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(StatusCellComponent); + component = fixture.componentInstance; + component.dataJob = TEST_JOB; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.ts new file mode 100644 index 0000000000..3a5927cd97 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-cell/status-cell.component.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input } from '@angular/core'; +import { DataJob } from '../../../../model/data-job.model'; + +@Component({ + selector: 'lib-status-cell', + templateUrl: './status-cell.component.html', + styleUrls: ['./status-cell.component.css'] +}) +export class StatusCellComponent { + @Input() dataJob: DataJob; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.css b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.css new file mode 100644 index 0000000000..4bc0214798 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.css @@ -0,0 +1,6 @@ +/* + * Copyright 2023-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* No-op. */ diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.html new file mode 100644 index 0000000000..a3d006ca9a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.html @@ -0,0 +1,18 @@ + + + + Not + Deployed + Disabled + Enabled + diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.spec.ts new file mode 100644 index 0000000000..21d35e1781 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.spec.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ExtractJobStatusPipe } from '../../../pipes/extract-job-status.pipe'; + +import { StatusPanelComponent } from './status-panel.component'; + +describe('StatusPanelComponent', () => { + let component: StatusPanelComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + StatusPanelComponent, + ExtractJobStatusPipe + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(StatusPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.ts new file mode 100644 index 0000000000..6da65372fe --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/status/status-panel/status-panel.component.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input } from '@angular/core'; + +import { DataJobDeployment } from '../../../../model'; + +@Component({ + selector: 'lib-status-panel', + templateUrl: './status-panel.component.html', + styleUrls: ['./status-panel.component.css'] +}) +export class StatusPanelComponent { + @Input() jobDeployments: DataJobDeployment[]; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/index.ts new file mode 100644 index 0000000000..6b5cb60602 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './widget-value.component'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.html b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.html new file mode 100644 index 0000000000..c9ab903bee --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.html @@ -0,0 +1,23 @@ + + + + {{ prop ? data[prop] : data}} + + + + +
+ +
+
+ + +
+ Loading ... +
+
+
diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.spec.ts new file mode 100644 index 0000000000..75e2159aa6 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.spec.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WidgetValueComponent } from './widget-value.component'; + +describe('WidgetValueComponent', () => { + let component: WidgetValueComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [WidgetValueComponent] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WidgetValueComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.ts new file mode 100644 index 0000000000..7c7ae563e3 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/components/widget-value/widget-value.component.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'lib-widget-value', + templateUrl: './widget-value.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class WidgetValueComponent { + @Input() observable$: Observable; + @Input() prop: string; + @Input() showErrorState: boolean; +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/attribute.directive.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/attribute.directive.spec.ts new file mode 100644 index 0000000000..80f23bbd28 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/attribute.directive.spec.ts @@ -0,0 +1,160 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ElementRef, Renderer2 } from '@angular/core'; + +import { AttributesDirective } from './attribute.directive'; + +describe('AttributesDirective', () => { + let nativeElementStub: any; + let elementRefStub: ElementRef; + let rendererStub: jasmine.SpyObj; + + let directive: AttributesDirective; + + beforeEach(() => { + nativeElementStub = {}; + elementRefStub = { + nativeElement: nativeElementStub + }; + rendererStub = jasmine.createSpyObj('renderer2', ['setAttribute', 'removeAttribute']); + + directive = new AttributesDirective(elementRefStub, rendererStub); + }); + + it('should verify directive instance is created', () => { + // Then + expect(directive).toBeDefined(); + }); + + it('should verify on no attributes wont execute renderer', () => { + // When + directive.ngOnChanges({}); + directive.ngOnInit(); + + // Then + expect(rendererStub.setAttribute).not.toHaveBeenCalled(); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + }); + + it('should verify when attributes are null wont execute renderer', () => { + // When + directive.attributes = null; + directive.ngOnChanges({}); + directive.ngOnInit(); + + // Then + expect(rendererStub.setAttribute).not.toHaveBeenCalled(); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + }); + + it('should verify when attributes are not Literal Object wont execute renderer', () => { + // When + directive.attributes = new Map() as any; + directive.ngOnChanges({}); + directive.ngOnInit(); + + // Then + expect(rendererStub.setAttribute).not.toHaveBeenCalled(); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + }); + + it('should verify when providing two times same attributes by value but different reference would execute only once', () => { + // When 1 + directive.attributes = { + title: 'some-title', + size: 10, + shape: 'square', + class: 'css-class-1, css-class-2, css-class-3', + tabIndex: 0, + 'data-cy': 'cypress-selector' + }; + directive.ngOnInit(); + + // Then 1 + expect(rendererStub.setAttribute).toHaveBeenCalledTimes(6); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + + // When 2 + directive.attributes = { + title: 'some-title', + size: 10, + shape: 'square', + class: 'css-class-1, css-class-2, css-class-3', + tabIndex: 0, + 'data-cy': 'cypress-selector' + }; + directive.ngOnChanges({}); + + // Then + expect(rendererStub.setAttribute).toHaveBeenCalledTimes(6); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + }); + + it('should verify will set expected attributes from provided Literal Object', () => { + // When + directive.attributes = { + title: 'element-title', + size: 10, + shape: 'square', + class: 'css-class-1, css-class-2, css-class-3', + 'aria-label': 'aria-element-title', + tabIndex: 0, + 'data-cy': 'cypress-selector' + }; + directive.ngOnInit(); + + // Then + expect(rendererStub.setAttribute).toHaveBeenCalledTimes(7); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + }); + + it('should verify will remove expected attributes from provided Literal Object', () => { + // When + directive.attributes = { + title: undefined, + size: 10, + shape: 'square', + class: 'css-class-1, css-class-2, css-class-3', + 'aria-label': null, + tabIndex: 0, + 'data-cy': 'cypress-selector', + 'data-index': false, + 'data-attribute': 'delete', + 'data-btn': 'false', + 'data-attr': '' + }; + directive.ngOnInit(); + + // Then + expect(rendererStub.setAttribute).toHaveBeenCalledTimes(5); + expect(rendererStub.removeAttribute).toHaveBeenCalledTimes(6); + }); + + it('should verify will set and then remove provided attributes', () => { + // When 1 + directive.attributes = { + title: 'some-title', + size: 10, + shape: 'square', + class: 'css-class-1, css-class-2, css-class-3', + tabIndex: 0, + 'data-cy': 'cypress-selector' + }; + directive.ngOnInit(); + + // Then 1 + expect(rendererStub.setAttribute).toHaveBeenCalledTimes(6); + expect(rendererStub.removeAttribute).not.toHaveBeenCalled(); + + // When 2 + directive.attributes = null; + directive.ngOnChanges({}); + + // Then 2 + expect(rendererStub.setAttribute).toHaveBeenCalledTimes(6); + expect(rendererStub.removeAttribute).toHaveBeenCalledTimes(6); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/attribute.directive.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/attribute.directive.ts new file mode 100644 index 0000000000..e656e4e108 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/attribute.directive.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Directive, ElementRef, Input, OnChanges, OnInit, Renderer2, SimpleChanges } from '@angular/core'; + +import { CollectionsUtil, PrimitivesNil, TaurusObject } from '@vdk/shared'; + +export interface Attributes { + [attribute: string]: PrimitivesNil; +} + +/** + * ** Directive that set provided object as Element attributes. + * + * @author gorankokin + */ +@Directive({ + selector: '[libSetAttributes]' +}) +export class AttributesDirective extends TaurusObject implements OnInit, OnChanges { + /** + * ** Input attributes that should be applied to host element. + */ + @Input() attributes: Attributes; + + private _attributesCopy: Attributes; + + /** + * ** Constructor. + */ + constructor(private readonly el: ElementRef, + private readonly renderer: Renderer2) { + super(); + } + + /** + * @inheritDoc + */ + ngOnChanges(_changes: SimpleChanges) { + this._transformAttributes(); + } + + /** + * @inheritDoc + */ + ngOnInit() { + this._transformAttributes(); + } + + private _transformAttributes(): void { + if (CollectionsUtil.isEqual(this.attributes, this._attributesCopy)) { + return; + } + + if (CollectionsUtil.isNil(this.attributes)) { + if (CollectionsUtil.isNil(this._attributesCopy)) { + return; + } + + CollectionsUtil.iterateObject(this._attributesCopy, (_attributeValue, attributeName) => { + this._removeAttribute(attributeName); + }); + + return; + } + + if (!CollectionsUtil.isLiteralObject(this.attributes)) { + return; + } + + this._attributesCopy = CollectionsUtil.cloneDeep(this.attributes); + + CollectionsUtil.iterateObject(this._attributesCopy, (attributeValue, attributeName) => { + this._setOrRemoveAttribute(attributeName, attributeValue); + }); + } + + private _setOrRemoveAttribute(attributeName: string, attributeValue: unknown): void { + if (AttributesDirective._isTruthy(attributeValue)) { + this._setAttribute(attributeName, attributeValue); + } else { + this._removeAttribute(attributeName); + } + } + + private _setAttribute(attributeName: string, attributeValue: unknown): void { + this.renderer.setAttribute(this.el.nativeElement, attributeName, attributeValue as string); + } + + private _removeAttribute(attributeName: string): void { + this.renderer.removeAttribute(this.el.nativeElement, attributeName); + } + + // eslint-disable-next-line @typescript-eslint/member-ordering,@typescript-eslint/no-explicit-any + private static _isTruthy(value: any): boolean { + return AttributesDirective._valueNotIn( + value, + [undefined, false, null, 'delete', 'false', ''] + ); + } + + // eslint-disable-next-line @typescript-eslint/member-ordering,@typescript-eslint/no-explicit-any + private static _valueNotIn(value: any, forbiddenValues: any[]): boolean { + return forbiddenValues.every((prop) => value !== prop); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/index.ts new file mode 100644 index 0000000000..0313106957 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/directives/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './attribute.directive'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/index.ts new file mode 100644 index 0000000000..37596cda59 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './modal-options'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/modal-options.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/modal-options.spec.ts new file mode 100644 index 0000000000..1c185bbad1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/modal-options.spec.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EditModalOptions, ConfirmationModalOptions, DeleteModalOptions } from './modal-options'; + +describe('ModalOptions', () => { + + it('DeleteModalOptions have initial values', () => { + const deleteModalOptions = new DeleteModalOptions(); + expect(deleteModalOptions.opened).toBeFalse(); + expect(deleteModalOptions.title).toBeDefined(); + expect(deleteModalOptions.message).toBeDefined(); + expect(deleteModalOptions.cancelBtn).toBeDefined(); + expect(deleteModalOptions.showCancelBtn).toBeTrue(); + expect(deleteModalOptions.okBtn).toBeDefined(); + expect(deleteModalOptions.showOkBtn).toBeTrue(); + expect(deleteModalOptions.showCloseX).toBeTrue(); + }); + + it('EditModalOptions have initial values', () => { + const editModalOptions = new EditModalOptions(); + expect(editModalOptions.opened).toBeFalse(); + expect(editModalOptions.title).toBeDefined(); + expect(editModalOptions.message).toBeDefined(); + expect(editModalOptions.cancelBtn).toBeDefined(); + expect(editModalOptions.showCancelBtn).toBeTrue(); + expect(editModalOptions.okBtn).toBeDefined(); + expect(editModalOptions.showOkBtn).toBeTrue(); + expect(editModalOptions.showCloseX).toBeTrue(); + }); + + it('ConfirmationModalOptions have initial values', () => { + const confirmationModalOptions = new ConfirmationModalOptions(); + expect(confirmationModalOptions.opened).toBeFalse(); + expect(confirmationModalOptions.title).toBeDefined(); + expect(confirmationModalOptions.message).toBeDefined(); + expect(confirmationModalOptions.cancelBtn).toBeDefined(); + expect(confirmationModalOptions.showCancelBtn).toBeTrue(); + expect(confirmationModalOptions.okBtn).toBeDefined(); + expect(confirmationModalOptions.showOkBtn).toBeTrue(); + expect(confirmationModalOptions.showCloseX).toBeTrue(); + }); + +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/modal-options.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/modal-options.ts new file mode 100644 index 0000000000..82a926db42 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/model/modal-options.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ModalOptions { + opened: boolean; + title: string; + message: string; + cancelBtn: string; + showCancelBtn: boolean; + okBtn: string; + showOkBtn: boolean; + showCloseX: boolean; + + infoText?: string; + warningText?: string; +} + +export class DeleteModalOptions implements ModalOptions { + opened: boolean; + title: string; + message: string; + cancelBtn: string; + showCancelBtn: boolean; + okBtn: string; + showOkBtn: boolean; + showCloseX: boolean; + + constructor() { + this.opened = false; + this.title = 'Delete'; + this.message = 'Are you sure you want to permanently delete this item?'; + this.cancelBtn = 'Cancel'; + this.showCancelBtn = true; + this.okBtn = 'Delete'; + this.showOkBtn = true; + this.showCloseX = true; + } +} + +export class EditModalOptions implements ModalOptions { + opened: boolean; + title: string; + message: string; + cancelBtn: string; + showCancelBtn: boolean; + okBtn: string; + showOkBtn: boolean; + showCloseX: boolean; + + constructor() { + this.opened = false; + this.title = 'Edit'; + this.message = ''; + this.cancelBtn = 'Cancel'; + this.showCancelBtn = true; + this.okBtn = 'Edit'; + this.showOkBtn = true; + this.showCloseX = true; + } +} + +export class ConfirmationModalOptions implements ModalOptions { + opened: boolean; + title: string; + message: string; + cancelBtn: string; + showCancelBtn: boolean; + okBtn: string; + showOkBtn: boolean; + showCloseX: boolean; + + constructor() { + this.opened = false; + this.title = 'Confirm'; + this.message = 'Are you sure?'; + this.cancelBtn = 'Cancel'; + this.showCancelBtn = true; + this.okBtn = 'Confirm'; + this.showOkBtn = true; + this.showCloseX = true; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/contacts-present.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/contacts-present.pipe.spec.ts new file mode 100644 index 0000000000..87f81deaa6 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/contacts-present.pipe.spec.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataJobContacts } from '../../model'; + +import { ContactsPresentPipe } from './contacts-present.pipe'; + +describe('ContactsPresentPipe', () => { + it('should verify instance is created', () => { + // When + const instance = new ContactsPresentPipe(); + + // Then + expect(instance).toBeDefined(); + }); + + describe('Methods::', () => { + let pipe: ContactsPresentPipe; + + beforeEach(() => { + pipe = new ContactsPresentPipe(); + }); + + describe('|transform|', () => { + const parameters: Array<{ contacts: DataJobContacts; expected: boolean }> = [ + { contacts: null, expected: false }, + { + contacts: { + notifiedOnJobSuccess: undefined, + notifiedOnJobDeploy: undefined, + notifiedOnJobFailurePlatformError: undefined, + notifiedOnJobFailureUserError: undefined + }, + expected: false + }, + { + contacts: { + notifiedOnJobSuccess: [], + notifiedOnJobDeploy: [], + notifiedOnJobFailurePlatformError: [], + notifiedOnJobFailureUserError: [] + }, + expected: false + }, + { + contacts: { + notifiedOnJobSuccess: ['alpha@abc.com'], + notifiedOnJobDeploy: [], + notifiedOnJobFailurePlatformError: [], + notifiedOnJobFailureUserError: [] + }, + expected: true + }, + { + contacts: { + notifiedOnJobSuccess: [], + notifiedOnJobDeploy: ['beta@abc.com'], + notifiedOnJobFailurePlatformError: [], + notifiedOnJobFailureUserError: [] + }, + expected: true + }, + { + contacts: { + notifiedOnJobSuccess: [], + notifiedOnJobDeploy: [], + notifiedOnJobFailurePlatformError: ['gama@abc.com'], + notifiedOnJobFailureUserError: [] + }, + expected: true + }, + { + contacts: { + notifiedOnJobSuccess: [], + notifiedOnJobDeploy: [], + notifiedOnJobFailurePlatformError: [], + notifiedOnJobFailureUserError: ['delta@abc.com'] + }, + expected: true + }, + { + contacts: { + notifiedOnJobSuccess: ['alpha@abc.com'], + notifiedOnJobDeploy: ['beta@abc.com'], + notifiedOnJobFailurePlatformError: ['gama@abc.com'], + notifiedOnJobFailureUserError: ['delta@abc.com'] + }, + expected: true + } + ]; + + let cnt = 0; + for (const params of parameters) { + cnt++; + + it(`should verify will return ${ (params.expected as unknown) as string } case ${ cnt }`, () => { + // When + const value = pipe.transform(params.contacts); + + // Then + expect(value).toEqual(params.expected); + }); + } + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/contacts-present.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/contacts-present.pipe.ts new file mode 100644 index 0000000000..a8441ecc9a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/contacts-present.pipe.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +import { CollectionsUtil } from '@vdk/shared'; + +import { DataJobContacts } from '../../model'; + +@Pipe({ + name: 'contactsPresent' +}) +export class ContactsPresentPipe implements PipeTransform { + /** + * @inheritDoc + */ + transform(contacts: DataJobContacts): boolean { + return CollectionsUtil.isDefined(contacts) && + ( + ContactsPresentPipe.contactIsPresent(contacts.notifiedOnJobSuccess) || + ContactsPresentPipe.contactIsPresent(contacts.notifiedOnJobDeploy) || + ContactsPresentPipe.contactIsPresent(contacts.notifiedOnJobFailureUserError) || + ContactsPresentPipe.contactIsPresent(contacts.notifiedOnJobFailurePlatformError) + ); + } + + // eslint-disable-next-line @typescript-eslint/member-ordering + private static contactIsPresent(contacts: string[]): boolean { + return CollectionsUtil.isArray(contacts) && contacts.length > 0; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/execution-success-rate.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/execution-success-rate.pipe.spec.ts new file mode 100644 index 0000000000..afa02c6fa2 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/execution-success-rate.pipe.spec.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataJobDeployment } from '../../model'; + +import { ExecutionSuccessRatePipe } from './execution-success-rate.pipe'; + +describe('ExecutionSuccessRatePipe', () => { + let localeId: string; + let pipe: ExecutionSuccessRatePipe; + let deployments: DataJobDeployment[]; + + beforeEach(() => { + localeId = 'en-US'; + pipe = new ExecutionSuccessRatePipe(localeId); + deployments = [ + { + successfulExecutions: 20, + failedExecutions: 5 + } as DataJobDeployment + ]; + }); + + it('should verify instance is created', () => { + // Then + expect(pipe).toBeDefined(); + }); + + describe('Methods::', () => { + describe('|transform|', () => { + it(`should verify will return '' when no deployments or Empty Array`, () => { + // When + const r1 = pipe.transform(null); + const r2 = pipe.transform(undefined); + const r3 = pipe.transform([]); + + // Then + expect(r1).toEqual(''); + expect(r2).toEqual(''); + expect(r3).toEqual(''); + }); + + it(`should verify will return '' when sum of all all executions is 0`, () => { + // Given + deployments[0].successfulExecutions = 0; + deployments[0].failedExecutions = 0; + + // When + const r = pipe.transform(deployments); + + // Then + expect(r).toEqual(''); + }); + + it('should verify will return 100% when no failed executions', () => { + // Given + deployments[0].failedExecutions = 0; + + // When + const r = pipe.transform(deployments); + + // Then + expect(r).toEqual('100.00%'); + }); + + it('should verify will return percent of success and number of failed executions (case 1)', () => { + // When + const r = pipe.transform(deployments); + + // Then + expect(r).toEqual('80.00% (5 failed)'); + }); + + it('should verify will return percent of success and number of failed executions (case 2)', () => { + // Given + deployments[0].successfulExecutions = 18; + deployments[0].failedExecutions = 15; + + // When + const r = pipe.transform(deployments); + + // Then + expect(r).toEqual('54.55% (15 failed)'); + }); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/execution-success-rate.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/execution-success-rate.pipe.ts new file mode 100644 index 0000000000..1e5667ecb1 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/execution-success-rate.pipe.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Inject, LOCALE_ID, Pipe, PipeTransform } from '@angular/core'; +import { PercentPipe } from '@angular/common'; + +import { CollectionsUtil } from '@vdk/shared'; + +import { DataJobDeployment } from '../../model'; + +@Pipe({ + name: 'executionSuccessRate' +}) +export class ExecutionSuccessRatePipe implements PipeTransform { + private readonly _percentPipe: PercentPipe; + + /** + * ** Constructor. + */ + constructor(@Inject(LOCALE_ID) readonly localeId: string) { + this._percentPipe = new PercentPipe(localeId); + } + + /** + * @inheritDoc + */ + transform(deployments: DataJobDeployment[]): string { + let result = ''; + + if (CollectionsUtil.isArrayEmpty(deployments)) { + return result; + } + + const firstDeployment = deployments[0]; + const allExecutions = firstDeployment.successfulExecutions + firstDeployment.failedExecutions; + + if (allExecutions === 0) { + return result; + } + + result += this._percentPipe.transform(firstDeployment.successfulExecutions / allExecutions, '1.2-2'); + + if (firstDeployment.failedExecutions > 0) { + result += ` (${ firstDeployment.failedExecutions } failed)`; + } + + return result; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-contacts.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-contacts.pipe.ts new file mode 100644 index 0000000000..69cf35fa9d --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-contacts.pipe.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'extractContacts' +}) +export class ExtractContactsPipe implements PipeTransform { + + static transform(contacts: string[]): string[] { + if (Array.isArray(contacts) && contacts.length) { + return contacts; + } else { + return []; + } + } + + transform(contacts: string[]): string[] { + return ExtractContactsPipe.transform(contacts); + } + +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-job-status.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-job-status.pipe.spec.ts new file mode 100644 index 0000000000..8d5dec6e99 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-job-status.pipe.spec.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { DataJobDeploymentDetails } from '../../model/data-job-details.model'; +import { ExtractJobStatusPipe } from './extract-job-status.pipe'; + +const TEST_JOB_DEPLOYMENT_DETAILS: DataJobDeploymentDetails = { + id: 'id002', + enabled: true, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + job_version: 'v001', + mode: 'special', + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + vdk_version: 'v002', + contacts: null, + /* eslint-disable @typescript-eslint/naming-convention */ + deployed_date: '2020-11-11T10:10:10Z', + deployed_by: 'pmitev', + resources: { + memory_limit: 1000, + memory_request: 1000, + cpu_limit: 0.5, + cpu_request: 0.5 + } + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +describe('ExtractJobStatusPipe', () => { + let pipe: ExtractJobStatusPipe; + let deploymentDetails: DataJobDeploymentDetails; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ExtractJobStatusPipe] }); + pipe = TestBed.inject(ExtractJobStatusPipe); + deploymentDetails = TEST_JOB_DEPLOYMENT_DETAILS; + }); + + it('can instantiate ExtractJobStatusPipe', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms empty deploymentDetails to NOT_DEPLOYED', () => { + expect(pipe.transform([])).toEqual('Not Deployed'); + }); + + it('transforms disabled deploymentDetails to DISABLED', () => { + deploymentDetails.enabled = false; + + const jobDeployments = [deploymentDetails]; + expect(pipe.transform(jobDeployments)).toEqual('Disabled'); + }); + + it('transforms enabled deploymentDetails to DISABLED', () => { + deploymentDetails.enabled = true; + + const jobExecutions = [deploymentDetails]; + expect(pipe.transform(jobExecutions)).toEqual('Enabled'); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-job-status.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-job-status.pipe.ts new file mode 100644 index 0000000000..b070784ae6 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/extract-job-status.pipe.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +import { DataJobStatus, StatusDetails } from '../../model'; + +@Pipe({ + name: 'extractJobStatus', + pure: false +}) +export class ExtractJobStatusPipe implements PipeTransform { + /** + * ** Extract Job Status from Details. + * + * - This method should be equal to instance method. + * - Methods: {@link ExtractJobStatusPipe.transform} + */ + static transform(jobDeployments: StatusDetails[]): DataJobStatus { + if (!jobDeployments?.length) { + return DataJobStatus.NOT_DEPLOYED; + } + + if (jobDeployments[jobDeployments.length - 1].enabled) { + return DataJobStatus.ENABLED; + } + + return DataJobStatus.DISABLED; + } + + /** + * @inheritDoc + * + * - This method should be equal to instance method. + * - Methods: {@link ExtractJobStatusPipe.transform} + */ + transform(jobDeployments: StatusDetails[]): DataJobStatus { + return ExtractJobStatusPipe.transform(jobDeployments); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-delta.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-delta.pipe.spec.ts new file mode 100644 index 0000000000..16f4efea24 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-delta.pipe.spec.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; + +import { DataJobExecution, DataJobExecutionStatus, DataJobExecutionType } from '../../model'; + +import { FormatDeltaPipe } from './format-delta.pipe'; + +const TEST_JOBS_EXECUTIONS = { + id: 'id001', + jobName: 'name001', + status: DataJobExecutionStatus.RUNNING, + startTime: new Date().toISOString(), + startedBy: 'aUserov', + type: DataJobExecutionType.SCHEDULED, + endTime: new Date().toISOString(), + opId: 'op001', + message: 'Message 001' +} as DataJobExecution; + +describe('FormatDeltaPipe', () => { + let pipe: FormatDeltaPipe; + let execution: DataJobExecution; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [FormatDeltaPipe] }); + pipe = TestBed.inject(FormatDeltaPipe); + execution = { ...TEST_JOBS_EXECUTIONS }; + }); + + it('can instantiate FormatDeltaPipe', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms missing startTime to valid value', (): void => { + execution.startTime = undefined; + execution.endTime = undefined; + expect(pipe.transform(execution)).toEqual(''); + }); + + it('transforms invalid EndData to valid delta', (): void => { + execution.startTime = substract(new Date().toISOString(), 1); + execution.endTime = null; + expect(pipe.transform(execution)).toBeDefined(); + }); + + it('transforms a out of range input to NaN delta', (): void => { + const currentDate = new Date().toISOString(); + execution.startTime = substract(currentDate, -10); + execution.endTime = currentDate; + expect(pipe.transform(execution)).toBe('N/A'); + }); + + it('transforms a seconds range input to valid delta', (): void => { + const currentDate = new Date().toISOString(); + execution.startTime = substract(currentDate, 10); + execution.endTime = currentDate; + expect(pipe.transform(execution)).toBe('10s'); + }); + + it('transforms a minutes range input to valid delta', (): void => { + const currentDate = new Date().toISOString(); + execution.startTime = substract(currentDate, 121); + execution.endTime = currentDate; + expect(pipe.transform(execution)).toBe('2m 1s'); + }); + + it('transforms an hours range input to valid delta', (): void => { + const currentDate = new Date().toISOString(); + execution.startTime = substract(currentDate, 7260); + execution.endTime = currentDate; + expect(pipe.transform(execution)).toBe('2h 1m'); + }); + + it('transforms a days range input to valid delta', (): void => { + const currentDate = new Date().toISOString(); + execution.startTime = substract(currentDate, 176400); + execution.endTime = currentDate; + expect(pipe.transform(execution)).toBe('2d 1h'); + }); + + const substract = (date: string, secondsToSubstract: number) => { + const d = new Date(date); + const result = d; + result.setTime(d.getTime() - (1000 * secondsToSubstract)); + + return result.toISOString(); + }; +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-delta.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-delta.pipe.ts new file mode 100644 index 0000000000..81b2431e96 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-delta.pipe.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +import { CollectionsUtil } from '@vdk/shared'; + +import { DataJobExecution } from '../../model'; + +/** + * Format Delta Pipe formats the delta of the execution start and end Time. + * The format is dynamic and contains generaly the last two leading fragment of the duration. + * + * For example: + * + * 1: If the duration is less than 1 min, the format is `${seconds}s` + * + * 2: If the duration is between 1 min and 59 mins, the format is `${minutes}m ${seconds}s` + * + * 3: If the duration is between 1 hour and 1 day, the format is `${hours}h ${minutes}m` + * + * 4: If the duration is more than 1 day, the format is `${days}d ${hours}h` + */ +@Pipe({ + name: 'formatDelta' +}) +export class FormatDeltaPipe implements PipeTransform { + static formatDelta(delta: number): string { + if (delta < 0) { + return 'N/A'; + } else if (delta < 60) { + return `${ Math.ceil(delta) }s`; + } else if (delta < 3600) { + const minute = Math.floor((delta / (60) % 60)); + const seconds = Math.floor(delta % 60); + + return `${ minute }m ${ seconds }s`; + } else if (delta < 86400) { + const hours = Math.floor((delta / (60 * 60) % 24)); + const minutes = Math.floor((delta / (60) % 60)); + + return `${ hours }h ${ minutes }m`; + } else { + const days = Math.floor((delta / (60 * 60 * 24))); + const hours = Math.floor((delta / (60 * 60) % 24)); + + return `${ days }d ${ hours }h`; + } + } + + /** + * @inheritDoc + */ + transform(execution: DataJobExecution): string { + if (CollectionsUtil.isNil(execution.startTime)) { + return ''; + } + + const delta = (FormatDeltaPipe._getEndTime(execution) - FormatDeltaPipe._getStartTime(execution)) / 1000; + + return FormatDeltaPipe.formatDelta(delta); + } + + // eslint-disable-next-line @typescript-eslint/member-ordering + private static _getStartTime(execution: DataJobExecution): number { + if (CollectionsUtil.isDefined(execution.startTime)) { + return new Date(execution.startTime).getTime(); + } + + return Date.now(); + } + + // eslint-disable-next-line @typescript-eslint/member-ordering + private static _getEndTime(execution: DataJobExecution): number { + if (CollectionsUtil.isDefined(execution.endTime)) { + return new Date(execution.endTime).getTime(); + } + + return Date.now(); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-duration.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-duration.pipe.ts new file mode 100644 index 0000000000..a784c710bf --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-duration.pipe.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; +import { FormatDeltaPipe } from './format-delta.pipe'; + +@Pipe({ + name: 'formatDuration' +}) +export class FormatDurationPipe implements PipeTransform { + /** + * @inheritDoc + */ + transform(durationSeconds: number): string { + return durationSeconds ? FormatDeltaPipe.formatDelta(durationSeconds) : null; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-schedule.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-schedule.pipe.spec.ts new file mode 100644 index 0000000000..f904e80c5e --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-schedule.pipe.spec.ts @@ -0,0 +1,414 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FormatSchedulePipe } from './format-schedule.pipe'; + +describe('FormatSchedulePipe', () => { + let pipe: FormatSchedulePipe; + + beforeEach(() => { + pipe = new FormatSchedulePipe(); + }); + + it('can instantiate FormatSchedulePipe', () => { + // Then + expect(pipe).toBeTruthy(); + }); + + describe('Methods::', () => { + describe('|transform|', () => { + it('should verify on missing schedule returns empty result', () => { + // Given + const schedule: string = null; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBe(''); + }); + + it('should verify on missing schedule returns default value', () => { + // Given + const schedule: string = null; + const defaultResult = 'default001'; + + //When + const result = pipe.transform(schedule, defaultResult); + + // Then + expect(result).toBe(defaultResult); + }); + + it('should verify will correctly translate cron "5 5 5 2 *"', () => { + // Given + const schedule = '5 5 5 2 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 05:05 AM'); + }); + + it('should verify will return parsing error message invalid cron "65 65 65 65 65"', () => { + // Given + const schedule = '65 65 65 65 65'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + + describe('testing minute range', () => { + it('should verify will correctly translate cron "0 11 10 7 *"', () => { + // Given + const schedule = '0 11 10 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:00 AM, on day 10 of the month, only in July'); + }); + + it('should verify will correctly translate cron "59 11 10 7 *"', () => { + // Given + const schedule = '59 11 10 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:59 AM, on day 10 of the month, only in July'); + }); + + it('should verify will return parsing error message invalid cron "60 11 10 7 *"', () => { + // Given + const schedule = '60 12 10 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + }); + + describe('testing hour range', () => { + it('should verify will correctly translate cron "30 0 10 7 *"', () => { + // Given + const schedule = '30 0 10 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 12:30 AM, on day 10 of the month, only in July'); + }); + + it('should verify will correctly translate cron "30 23 10 7 *"', () => { + // Given + const schedule = '30 23 10 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 PM, on day 10 of the month, only in July'); + }); + + it('should verify will return parsing error message invalid cron "30 24 10 7 *"', () => { + // Given + const schedule = '30 24 10 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + }); + + describe('testing day range', () => { + it('should verify will correctly translate cron "30 11 1 7 *"', () => { + // Given + const schedule = '30 11 1 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 1 of the month, only in July'); + }); + + it('should verify will correctly translate cron "30 11 31 7 *"', () => { + // Given + const schedule = '30 11 31 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 31 of the month, only in July'); + }); + + it('should verify will return parsing error message invalid cron "30 11 0 7 *"', () => { + // Given + const schedule = '30 11 0 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + + it('should verify will return parsing error message invalid cron "30 11 32 7 *"', () => { + // Given + const schedule = '30 11 32 7 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + }); + + describe('testing month range', () => { + it('should verify will correctly translate cron "30 11 10 1 *"', () => { + // Given + const schedule = '30 11 10 1 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 10 of the month, only in January'); + }); + + it('should verify will correctly translate cron "30 11 10 12 *"', () => { + // Given + const schedule = '30 11 10 12 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 10 of the month, only in December'); + }); + + it('should verify will return parsing error message invalid cron "30 11 10 0 *"', () => { + // Given + const schedule = '30 11 10 0 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + + it('should verify will return parsing error message invalid cron "30 11 10 13 *"', () => { + // Given + const schedule = '30 11 10 13 *'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + }); + + describe('testing day of week range', () => { + it('should verify will correctly translate cron "30 11 10 7 0"', () => { + // Given + const schedule = '30 11 10 7 0'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 10 of the month, and on Sunday, only in July'); + }); + + it('should verify will correctly translate cron "30 11 10 7 6"', () => { + // Given + const schedule = '30 11 10 7 6'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 10 of the month, and on Saturday, only in July'); + }); + + it('should verify will correctly translate cron "30 11 10 7 7"', () => { + // Given + const schedule = '30 11 10 7 7'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('At 11:30 AM, on day 10 of the month, and on Sunday, only in July'); + }); + + it('should verify will return parsing error message invalid cron "30 11 10 7 8"', () => { + // Given + const schedule = '30 11 10 7 8'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + }); + + describe('testing non-standard cron expressions', () => { + it('should verify will correctly translate cron "@hourly"', () => { + // Given + const schedule = '@hourly'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('Run once an hour at the beginning of the hour'); + }); + + it('should verify will correctly translate cron "@daily" and "@midnight"', () => { + // Given + const schedule1 = '@daily'; + const schedule2 = '@midnight'; + + // When + const result1 = pipe.transform(schedule1); + const result2 = pipe.transform(schedule2); + + // Then + expect(result1).toBeDefined(); + expect(result1).toContain('Run once a day at midnight'); + expect(result2).toBeDefined(); + expect(result2).toContain('Run once a day at midnight'); + }); + + it('should verify will correctly translate cron "@weekly"', () => { + // Given + const schedule = '@weekly'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('Run once a week at midnight on Sunday morning'); + }); + + it('should verify will correctly translate cron "@monthly"', () => { + // Given + const schedule = '@monthly'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain('Run once a month at midnight of the first day of the month'); + }); + + it('should verify will correctly translate cron "@yearly" and "@annually"', () => { + // Given + const schedule1 = '@yearly'; + const schedule2 = '@annually'; + + // When + const result1 = pipe.transform(schedule1); + const result2 = pipe.transform(schedule2); + + // Then + expect(result1).toBeDefined(); + expect(result1).toContain('Run once a year at midnight of 1 January'); + expect(result2).toBeDefined(); + expect(result2).toContain('Run once a year at midnight of 1 January'); + }); + + it('should verify will return parsing error message invalid cron "yearly"', () => { + // Given + const schedule = 'yearly'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + + it('should verify will return parsing error message invalid cron "some random text"', () => { + // Given + const schedule = 'some random text'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + + it('should verify will return parsing error message invalid cron "some random text"', () => { + // Given + const schedule = 'some random text'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + + it('should verify will return parsing error message invalid cron "yearly"', () => { + // Given + const schedule = 'yearly'; + + // When + const result = pipe.transform(schedule); + + // Then + expect(result).toBeDefined(); + expect(result).toContain(`Invalid Cron expression "${ schedule }"`); + }); + }); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-schedule.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-schedule.pipe.ts new file mode 100644 index 0000000000..b466b2d515 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/format-schedule.pipe.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-underscore-dangle */ + +import { Pipe, PipeTransform } from '@angular/core'; + +import cronstrue from 'cronstrue'; + +import { CollectionsUtil } from '@vdk/shared'; + +@Pipe({ + name: 'formatSchedule' +}) +export class FormatSchedulePipe implements PipeTransform { + private static _fallbackTransformNonStandardCron(cron: string): string { + const match = `${ cron }`.trim().match(/^@hourly|@daily|@midnight|@weekly|@monthly|@yearly|@annually$/); + + if (CollectionsUtil.isNil(match)) { + throw new Error('Cron expression cannot be null or undefined.'); + } + + switch (match.input) { + case '@hourly': + return 'Run once an hour at the beginning of the hour'; + case '@daily': + case '@midnight': + return 'Run once a day at midnight'; + case '@weekly': + return 'Run once a week at midnight on Sunday morning'; + case '@monthly': + return 'Run once a month at midnight of the first day of the month'; + case '@yearly': + case '@annually': + return 'Run once a year at midnight of 1 January'; + default: + throw new Error('Cron expression is NOT nonstandard predefined scheduling definition.'); + } + } + + /** + * @inheritDoc + * + * - Cron schedule default format from kubernetes https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/ + * - Time in UTC + */ + transform(cronSchedule: string, defaultResult?: string): string { + try { + const defaultValue = defaultResult ?? ''; + + if (!cronSchedule) { + return defaultValue; + } + + //TODO : https://github.com/bradymholt/cRonstrue/issues/94 + // cronstrue doesn't support timezones. Need to use another library + return cronstrue.toString( + cronSchedule, + { + monthStartIndexZero: false, + dayOfWeekStartIndexZero: true + } + ); + } catch (e) { + try { + return FormatSchedulePipe._fallbackTransformNonStandardCron(cronSchedule); + } catch (_e) { + console.error(`Parsing error. Cron expression "${ cronSchedule }"`); + + return `Invalid Cron expression "${ cronSchedule }"`; + } + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/index.ts new file mode 100644 index 0000000000..262efdda24 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './contacts-present.pipe'; +export * from './execution-success-rate.pipe'; +export * from './extract-job-status.pipe'; +export * from './format-delta.pipe'; +export * from './format-schedule.pipe'; +export * from './parse-epoch.pipe'; +export * from './parse-next-run.pipe'; +export * from './extract-contacts.pipe'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-epoch.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-epoch.pipe.spec.ts new file mode 100644 index 0000000000..9a5c3640c6 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-epoch.pipe.spec.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { ParseEpochPipe } from './parse-epoch.pipe'; + +describe('ParseEpochPipe', () => { + let pipe: ParseEpochPipe; + const TEST_EPOCH_SECONDS = 1522668899; + const MILLIS_MULTIPLIER = 1000; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ParseEpochPipe] }); + pipe = TestBed.inject(ParseEpochPipe); + }); + + it('can instantiate', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms missing epoch to emtpy result', () => { + expect(pipe.transform(-1)).toBeNull(); + }); + + it('transforms valid epoch to valid result', () => { + const result = pipe.transform(TEST_EPOCH_SECONDS); + expect(result).toBeDefined(); + expect(result).toEqual(new Date(TEST_EPOCH_SECONDS * MILLIS_MULTIPLIER)); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-epoch.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-epoch.pipe.ts new file mode 100644 index 0000000000..87ce75b4c8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-epoch.pipe.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'parseEpoch' +}) +export class ParseEpochPipe implements PipeTransform { + /** + * ** Transform to Epoch time. + * + * - This method should be equal to instance method. + * - Methods: {@link ParseEpochPipe.transform} + */ + static transform(nextRunEpochSeconds: number): Date { + if (nextRunEpochSeconds < 0) { + return null; + } + + return new Date(nextRunEpochSeconds * 1000); + } + + /** + * @inheritDoc + * + * - This method should be equal to instance method. + * - Methods: {@link ParseEpochPipe.transform} + */ + transform(nextRunEpochSeconds: number): Date { + return ParseEpochPipe.transform(nextRunEpochSeconds); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-next-run.pipe.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-next-run.pipe.spec.ts new file mode 100644 index 0000000000..9169d2c54d --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-next-run.pipe.spec.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { ParseNextRunPipe } from './parse-next-run.pipe'; + +describe('ParseNextRunPipe', () => { + let pipe: ParseNextRunPipe; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [ParseNextRunPipe] }); + pipe = TestBed.inject(ParseNextRunPipe); + }); + + it('can instantiate ParseNextRunPipe', () => { + expect(pipe).toBeTruthy(); + }); + + describe('Methods::', () => { + describe('|transform|', () => { + it('transforms invalid cron to null date', () => { + const cron: string = null; + const nextExecution = 1; + expect(pipe.transform(cron, nextExecution)).toBe(null); + }); + + it('transforms invalid nextExecution to valid date', () => { + const cron = '* * * * *'; + const nextExecution: number = null; + expect(pipe.transform(cron, nextExecution)).toBeDefined(); + }); + + it('transforms missing nextExecution to valid date', () => { + const cron = '* * * * *'; + expect(pipe.transform(cron)).toBeDefined(); + }); + + it('transforms valid cron to valid date', () => { + const cron = '5 5 5 2 *'; + const result = pipe.transform(cron); + expect(result).toBeDefined(); + expect(result.getMonth()).toBe(1); // 1 means Feb + }); + + it(`should verify will return next first run for cron "0 12 * * *" to DateTime in UTC`, (done) => { + // Given + const cron = '0 12 * * *'; + const expected = new Date(); + let useTimeout = false; + + if (expected.getUTCHours() >= 12) { + expected.setUTCDate(expected.getUTCDate() + 1); + } else if (expected.getUTCHours() === 11 && + expected.getUTCMinutes() === 59 && + expected.getUTCSeconds() === 59) { + + useTimeout = true; + expected.setUTCDate(expected.getUTCDate() + 1); + } + + const y = expected.getUTCFullYear(); + const _m: number = expected.getUTCMonth() + 1; + const _d: number = expected.getUTCDate(); + + let m = `${ _m }`; + let d = `${ _d }`; + + if (_m < 10) { + m = `0${ _m }`; + } + + if (_d < 10) { + d = `0${ _d }`; + } + + if (useTimeout) { + setTimeout(() => { + // When + const date = pipe.transform(cron, 1); + + // Then + console.log(date.toISOString()); + expect(date.toISOString()).toEqual(`${ y }-${ m }-${ d }T12:00:00.000Z`); + + done(); + }); + } else { + // When + const date = pipe.transform(cron, 1); + + // Then + console.log(date.toISOString()); + expect(date.toISOString()).toEqual(`${ y }-${ m }-${ d }T12:00:00.000Z`); + + done(); + } + }, 5000); + + it(`should verify will return next second run for cron "45 0/12 * * *" to DateTime in UTC`, (done) => { + // Given + const cron = '45 0/12 * * *'; + const expected = new Date(); + let useTimeout = false; + let lunchTime = false; + + let hh: string; + let mm: string; + + expected.setUTCDate(expected.getUTCDate() + 1); + + if (expected.getUTCHours() > 12) { + lunchTime = true; + } else if (expected.getUTCHours() === 12 && + expected.getUTCMinutes() === 44 && + expected.getUTCSeconds() === 59) { + + lunchTime = true; + useTimeout = true; + } + + const y = expected.getUTCFullYear(); + const _m: number = expected.getUTCMonth() + 1; + const _d: number = expected.getUTCDate(); + + let m = `${ _m }`; + let d = `${ _d }`; + + if (_m < 10) { + m = `0${ m }`; + } + + if (_d < 10) { + d = `0${ d }`; + } + + if (lunchTime) { + hh = '12'; + mm = '45'; + } else { + hh = '00'; + mm = '45'; + } + + if (useTimeout) { + setTimeout(() => { + // When + const date = pipe.transform(cron, 2); + + // Then + console.log(date.toISOString()); + expect(date.toISOString()).toEqual(`${ y }-${ m }-${ d }T${ hh }:${ mm }:00.000Z`); + + done(); + }); + } else { + // When + const date = pipe.transform(cron, 2); + + // Then + console.log(date.toISOString()); + expect(date.toISOString()).toEqual(`${ y }-${ m }-${ d }T${ hh }:${ mm }:00.000Z`); + + done(); + } + }, 5000); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-next-run.pipe.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-next-run.pipe.ts new file mode 100644 index 0000000000..cfc3bb8698 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/pipes/parse-next-run.pipe.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +import * as parser from 'cron-parser'; + +@Pipe({ + name: 'parseNextRun' +}) +export class ParseNextRunPipe implements PipeTransform { + /** + * @inheritDoc + */ + transform(cron: string, nextExecution?: number): Date { + if (!cron) { + return null; + } + + if (!nextExecution) { + nextExecution = 1; + } + + let result: Date; + try { + const parsedDate = parser.parseExpression(cron, { utc: true }); + for (let i = 0; i < nextExecution; i++) { + result = parsedDate.next().toDate(); + } + } catch (e) { + result = null; + console.error('Error parsing next run', e); + } + return result; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/cron.util.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/cron.util.spec.ts new file mode 100644 index 0000000000..0a1b9e5b94 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/cron.util.spec.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CronUtil } from './cron.util'; + +describe('DateUtil', () => { + it('expect null argument to return null result', () => { + expect(CronUtil.getNextExecutionErrors(null)).toBe('No schedule cron configured for this job'); + }); + + it('expect invalid argument to return error text', () => { + expect(CronUtil.getNextExecutionErrors('* * * * * * *')).toContain(''); + }); + + it('expect valid argument to return null', () => { + expect(CronUtil.getNextExecutionErrors('5 5 5 2 *')).toEqual(null); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/cron.util.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/cron.util.ts new file mode 100644 index 0000000000..72888c1137 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/cron.util.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as parser from 'cron-parser'; + +export class CronUtil { + static getNextExecutionErrors(cron: string): string { + if (!cron) { + return 'No schedule cron configured for this job'; + } + try { + parser.parseExpression(cron); + return null; // parsing successful, reset flag + } catch (e: unknown) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `Could not extract next executions from the cron expression: ${ e }`; + } + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/data-job.util.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/data-job.util.spec.ts new file mode 100644 index 0000000000..80f2dd4dd9 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/data-job.util.spec.ts @@ -0,0 +1,256 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { + DataJobExecution, + DataJobExecutionDetails, + DataJobExecutionStatus, + DataJobExecutionStatusDeprecated, + DataJobExecutionType +} from '../../model'; + +import { DataJobUtil } from './data-job.util'; + +describe('DataJobUtil', () => { + describe('|isJobRunningPredicate|', () => { + it('should verify will return true if status is RUNNING', () => { + // Given + const execution: DataJobExecutionDetails = { + status: DataJobExecutionStatusDeprecated.RUNNING + } as any; + + // When + const response = DataJobUtil.isJobRunningPredicate(execution); + + // Then + expect(response).toBeTrue(); + }); + + it('should verify will return true if status is SUBMITTED', () => { + // Given + const execution: DataJobExecutionDetails = { + status: DataJobExecutionStatusDeprecated.SUBMITTED + } as any; + + // When + const response = DataJobUtil.isJobRunningPredicate(execution); + + // Then + expect(response).toBeTrue(); + }); + + it('should verify will return false if status is different from RUNNING or SUBMITTED', () => { + // Given + const execution1: DataJobExecutionDetails = { + status: DataJobExecutionStatusDeprecated.USER_ERROR + } as any; + const execution2: DataJobExecutionDetails = { + status: DataJobExecutionStatusDeprecated.SKIPPED + } as any; + const execution3: DataJobExecutionDetails = { + status: DataJobExecutionStatusDeprecated.PLATFORM_ERROR + } as any; + const execution4: DataJobExecutionDetails = { + status: DataJobExecutionStatusDeprecated.SUCCEEDED + } as any; + + // When + const response1 = DataJobUtil.isJobRunningPredicate(execution1); + const response2 = DataJobUtil.isJobRunningPredicate(execution2); + const response3 = DataJobUtil.isJobRunningPredicate(execution3); + const response4 = DataJobUtil.isJobRunningPredicate(execution4); + + // Then + expect(response1).toBeFalse(); + expect(response2).toBeFalse(); + expect(response3).toBeFalse(); + expect(response4).toBeFalse(); + }); + }); + + describe('|isJobRunning|', () => { + it('should verify will invoke correct method', () => { + // Given + const spy = spyOn(DataJobUtil, 'isJobRunningPredicate').and.callThrough(); + const executions: DataJobExecutionDetails[] = [ + { status: DataJobExecutionStatusDeprecated.RUNNING } + ] as any; + + // When + DataJobUtil.isJobRunning(executions); + + // Then + expect(spy.calls.argsFor(0)[0]).toEqual(executions[0]); + }); + + it('should verify will invoke correct method until find RUNNING or SUBMITTED status', () => { + // Given + const spy = spyOn(DataJobUtil, 'isJobRunningPredicate').and.callThrough(); + const executions: DataJobExecutionDetails[] = [ + { status: DataJobExecutionStatusDeprecated.FAILED }, + { status: DataJobExecutionStatusDeprecated.PLATFORM_ERROR }, + { status: DataJobExecutionStatusDeprecated.SUBMITTED }, + { status: DataJobExecutionStatusDeprecated.RUNNING } + ] as any; + + // When + DataJobUtil.isJobRunning(executions); + + // Then + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.calls.argsFor(0)[0]).toEqual(executions[0]); + expect(spy.calls.argsFor(1)[0]).toEqual(executions[1]); + expect(spy.calls.argsFor(2)[0]).toEqual(executions[2]); + }); + + it('should verify will invoke correct method with all elements no RUNNING or SUBMITTED status', () => { + // Given + const spy = spyOn(DataJobUtil, 'isJobRunningPredicate').and.callThrough(); + const executions: DataJobExecutionDetails[] = [ + { status: DataJobExecutionStatusDeprecated.FAILED }, + { status: DataJobExecutionStatusDeprecated.PLATFORM_ERROR }, + { status: DataJobExecutionStatusDeprecated.SUCCEEDED }, + { status: DataJobExecutionStatusDeprecated.SKIPPED } + ] as any; + + // When + DataJobUtil.isJobRunning(executions); + + // Then + expect(spy).toHaveBeenCalledTimes(4); + expect(spy.calls.argsFor(0)[0]).toEqual(executions[0]); + expect(spy.calls.argsFor(1)[0]).toEqual(executions[1]); + expect(spy.calls.argsFor(2)[0]).toEqual(executions[2]); + expect(spy.calls.argsFor(3)[0]).toEqual(executions[3]); + }); + }); + + describe('|convertFromExecutionDetailsToExecutionState|', () => { + let executionDetails: DataJobExecutionDetails; + let expectedExecution: DataJobExecution; + + beforeEach(() => { + executionDetails = { + id: 'id001', + job_name: 'job001', + type: 'manual', + status: DataJobExecutionStatusDeprecated.SUBMITTED, + start_time: new Date().toISOString(), + started_by: 'aUserov', + end_time: new Date().toISOString(), + op_id: 'op001', + message: 'message001', + logs_url: 'http://url', + deployment: { + schedule: { + schedule_cron: '5 5 5 5 *' + }, + id: 'id002', + enabled: true, + job_version: '002', + mode: 'test_mode', + vdk_version: '002', + resources: { + memory_limit: 1000, + memory_request: 1000, + cpu_limit: 0.5, + cpu_request: 0.5 + }, + deployed_date: '2020-11-11T10:10:10Z', + deployed_by: 'pmitev' + } + }; + expectedExecution = { + id: 'id001', + jobName: 'job001', + type: DataJobExecutionType.MANUAL, + status: DataJobExecutionStatus.SUBMITTED, + startTime: executionDetails.start_time, + startedBy: 'aUserov', + endTime: executionDetails.end_time, + opId: 'op001', + message: 'message001', + logsUrl: 'http://url', + deployment: { + schedule: { + scheduleCron: '5 5 5 5 *' + }, + id: 'id002', + enabled: true, + jobVersion: '002', + mode: 'test_mode', + vdkVersion: '002', + resources: { + memoryLimit: 1000, + memoryRequest: 1000, + cpuLimit: 0.5, + cpuRequest: 0.5 + }, + deployedDate: '2020-11-11T10:10:10Z', + deployedBy: 'pmitev' + } + }; + }); + + it('should verify will return empty execution when null and undefined provided', () => { + // When + const res1 = DataJobUtil.convertFromExecutionDetailsToExecutionState(null); + const res2 = DataJobUtil.convertFromExecutionDetailsToExecutionState(undefined); + + // Then + expect(res1).toEqual({ id: null }); + expect(res2).toEqual({ id: null }); + }); + + it('should verify will correctly convert case 1', () => { + // When + const converted = DataJobUtil.convertFromExecutionDetailsToExecutionState(executionDetails); + + // Then + expect(converted).toEqual(expectedExecution); + }); + + it('should verify will correctly convert case 2', () => { + // Given + delete executionDetails.deployment.resources; + expectedExecution.deployment.resources = {} as any; + + // When + const converted = DataJobUtil.convertFromExecutionDetailsToExecutionState(executionDetails); + + // Then + expect(converted).toEqual(expectedExecution); + }); + + it('should verify will correctly convert case 3', () => { + // Given + delete executionDetails.deployment.schedule; + expectedExecution.deployment.schedule = {} as any; + + // When + const converted = DataJobUtil.convertFromExecutionDetailsToExecutionState(executionDetails); + + // Then + expect(converted).toEqual(expectedExecution); + }); + + it('should verify will correctly convert case 4', () => { + // Given + delete executionDetails.deployment; + expectedExecution.deployment = { + schedule: {}, + resources: {} + } as any; + + // When + const converted = DataJobUtil.convertFromExecutionDetailsToExecutionState(executionDetails); + + // Then + expect(converted).toEqual(expectedExecution); + }); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/data-job.util.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/data-job.util.ts new file mode 100644 index 0000000000..9d065939f0 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/data-job.util.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CollectionsUtil } from '@vdk/shared'; + +import { + DataJobDeployment, + DataJobExecution, + DataJobExecutionDetails, + DataJobExecutionStatus, + DataJobExecutionStatusDeprecated, + DataJobExecutionType +} from '../../model'; + +/** + * ** Utils for Data Job. + */ +export class DataJobUtil { + /** + * ** Predicate for Job Running. + */ + static isJobRunningPredicate(jobExecution: DataJobExecution | DataJobExecutionDetails): boolean { + return (jobExecution as DataJobExecution).status === DataJobExecutionStatus.RUNNING || + (jobExecution as DataJobExecution).status === DataJobExecutionStatus.SUBMITTED || + (jobExecution as DataJobExecutionDetails).status === DataJobExecutionStatusDeprecated.RUNNING || + (jobExecution as DataJobExecutionDetails).status === DataJobExecutionStatusDeprecated.SUBMITTED; + } + + /** + * ** Find if some Job is running in provided Executions. + */ + static isJobRunning(jobExecutions: DataJobExecution[] | DataJobExecutionDetails[]): boolean { + // eslint-disable-next-line @typescript-eslint/unbound-method + return jobExecutions.findIndex(DataJobUtil.isJobRunningPredicate) !== -1; + } + + static convertFromExecutionDetailsToExecutionState(jobExecutionDetails: DataJobExecutionDetails): DataJobExecution { + if (CollectionsUtil.isNil(jobExecutionDetails)) { + return { + id: null + }; + } + + const execution: DataJobExecution = { + id: jobExecutionDetails.id, + jobName: jobExecutionDetails.job_name, + opId: jobExecutionDetails.op_id, + status: jobExecutionDetails.status.toUpperCase() as DataJobExecutionStatus, + startedBy: jobExecutionDetails.started_by, + startTime: jobExecutionDetails.start_time, + endTime: jobExecutionDetails.end_time, + message: jobExecutionDetails.message, + type: jobExecutionDetails.type.toUpperCase() as DataJobExecutionType, + logsUrl: jobExecutionDetails.logs_url, + deployment: { + schedule: {}, + resources: {} + } as DataJobDeployment + }; + + if (CollectionsUtil.isLiteralObject(jobExecutionDetails.deployment)) { + execution.deployment.id = jobExecutionDetails.deployment.id; + execution.deployment.enabled = jobExecutionDetails.deployment.enabled; + execution.deployment.jobVersion = jobExecutionDetails.deployment.job_version; + execution.deployment.vdkVersion = jobExecutionDetails.deployment.vdk_version; + execution.deployment.mode = jobExecutionDetails.deployment.mode; + execution.deployment.deployedDate = jobExecutionDetails.deployment.deployed_date; + execution.deployment.deployedBy = jobExecutionDetails.deployment.deployed_by; + + if (CollectionsUtil.isLiteralObject(jobExecutionDetails.deployment.schedule)) { + execution.deployment.schedule.scheduleCron = jobExecutionDetails.deployment.schedule.schedule_cron; + } + + if (CollectionsUtil.isLiteralObject(jobExecutionDetails.deployment.resources)) { + execution.deployment.resources.cpuRequest = jobExecutionDetails.deployment.resources.cpu_request; + execution.deployment.resources.cpuLimit = jobExecutionDetails.deployment.resources.cpu_limit; + execution.deployment.resources.memoryRequest = jobExecutionDetails.deployment.resources.memory_request; + execution.deployment.resources.memoryLimit = jobExecutionDetails.deployment.resources.memory_limit; + } + } + + return execution; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/date.util.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/date.util.spec.ts new file mode 100644 index 0000000000..685bc98a29 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/date.util.spec.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataJobExecution, DataJobExecutionStatus, DataJobExecutionType } from '../../model'; + +import { DateUtil } from './date.util'; + +const LESSER_DATE_JOBS_EXECUTION: DataJobExecution = { + id: 'oneId', + startTime: new Date(1).toISOString(), + jobName: 'oneJob', + status: DataJobExecutionStatus.SUBMITTED, + startedBy: '', + type: DataJobExecutionType.MANUAL, + endTime: new Date(1).toISOString(), + opId: 'oneOp', + message: 'oneMessage', + logsUrl: 'https::/logs.com' +}; + +const GREATER_DATE_JOBS_EXECUTION: DataJobExecution = { + id: 'twoId', + startTime: new Date(2).toISOString(), + jobName: 'twoJob', + status: DataJobExecutionStatus.SUBMITTED, + startedBy: '', + type: DataJobExecutionType.MANUAL, + endTime: new Date(2).toISOString(), + opId: 'twoOp', + message: 'twoMessage', + logsUrl: 'https::/logs.com' +}; + +describe('DateUtil', () => { + it('Compare Dates Asc for equal left and right', () => { + expect(DateUtil.compareDatesAsc(LESSER_DATE_JOBS_EXECUTION, LESSER_DATE_JOBS_EXECUTION)).toEqual(0); + }); + + it('Compare Dates Asc for greater left', () => { + expect(DateUtil.compareDatesAsc(GREATER_DATE_JOBS_EXECUTION, LESSER_DATE_JOBS_EXECUTION)).toBeGreaterThan(0); + }); + + it('Compare Dates Asc for greater right', () => { + expect(DateUtil.compareDatesAsc(LESSER_DATE_JOBS_EXECUTION, GREATER_DATE_JOBS_EXECUTION)).toBeLessThan(0); + }); + + it('GetDateInUTC', () => { + expect(DateUtil.normalizeToUTC('2021-12-10T10:12:12Z').getHours()).toEqual(10); + }); + +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/date.util.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/date.util.ts new file mode 100644 index 0000000000..427f0a18bc --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/date.util.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataJobExecution } from '../../model'; + +export class DateUtil { + static compareDatesAsc(left: DataJobExecution, right: DataJobExecution): number { + const leftStartTime = left.startTime ?? 0; + const rightStartTime = right.endTime ?? 0; + + return new Date(leftStartTime).getTime() - new Date(rightStartTime).getTime(); + } + + static normalizeToUTC(dateISO: string): Date { + return new Date(dateISO.replace(/Z$/, '')); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/error.util.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/error.util.ts new file mode 100644 index 0000000000..defab682d8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/error.util.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApolloError } from '@apollo/client/core'; + +import { CollectionsUtil } from '@vdk/shared'; + +/** + * ** Error Utils class. + * + * @author gorankokin + */ +export class ErrorUtil { + /** + * ** Extract root Error depending of the format. + */ + static extractError(error: Error): Error { + if (error instanceof ApolloError && CollectionsUtil.isDefined(error.networkError)) { + return error.networkError; + } + + return error; + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/index.ts new file mode 100644 index 0000000000..b76cc42545 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './cron.util'; +export * from './data-job.util'; +export * from './date.util'; +export * from './error.util'; +export * from './string.util'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/string.util.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/string.util.spec.ts new file mode 100644 index 0000000000..6c7751a1d4 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/string.util.spec.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { StringUtil } from './string.util'; + +describe('StringUtil', () => { + it('parsing string with single var', () => { + expect(StringUtil.stringFormat('Hello {0}', 'World!')).toBe('Hello World!'); + }); + + it('parsing string with multiple var', () => { + expect(StringUtil.stringFormat('Hello {0} {1}', 'beautiful', 'World!')).toBe('Hello beautiful World!'); + }); + + it('parsing string with multiple same var', () => { + expect(StringUtil.stringFormat('Hello {0} {0}', 'beautiful', 'World!')).toBe('Hello beautiful beautiful'); + }); + + it('parsing string with same var', () => { + expect(StringUtil.stringFormat('Hello {0} {0}', 'beautiful')).toBe('Hello beautiful beautiful'); + }); +}); diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/string.util.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/string.util.ts new file mode 100644 index 0000000000..44e90638b5 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/shared/utils/string.util.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export class StringUtil { + static stringFormat = (str: string, ...args: string[]) => + str.replace(/{(\d+)}/g, (match, index: number) => args[index] || ''); +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/actions/data-jobs.actions.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/actions/data-jobs.actions.ts new file mode 100644 index 0000000000..b23441aa1c --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/actions/data-jobs.actions.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ** Action type Fetch Data Jobs. + */ +export const FETCH_DATA_JOBS = '[feature::data-pipelines] Fetch Data Jobs'; + +/** + * ** Action type Fetch Data Job. + */ +export const FETCH_DATA_JOB = '[feature::data-pipelines] Fetch Data Job'; + +/** + * ** Action type Fetch Data Job executions. + */ +export const FETCH_DATA_JOB_EXECUTIONS = '[feature::data-pipelines] Fetch Data Job Executions'; + +/** + * ** Action type Update Data job. + */ +export const UPDATE_DATA_JOB = '[feature::data-pipelines] Update Data Job'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/actions/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/actions/index.ts new file mode 100644 index 0000000000..e885d54773 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/actions/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs.actions'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/data-jobs.effects.spec.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/data-jobs.effects.spec.ts new file mode 100644 index 0000000000..d2b75ed2a8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/data-jobs.effects.spec.ts @@ -0,0 +1,518 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { TestBed, waitForAsync } from '@angular/core/testing'; + +import { Observable, throwError } from 'rxjs'; + +import { marbles } from 'rxjs-marbles/jasmine'; + +import { provideMockActions } from '@ngrx/effects/testing'; + +import { ApolloQueryResult } from '@apollo/client/core'; + +import { + ComponentFailed, + ComponentLoaded, + ComponentModel, + ComponentService, + ComponentStateImpl, + ComponentUpdate, + GenericAction, + LOADED, + LOADING, + RouterState, + RouteState, + StatusType +} from '@vdk/shared'; + +import { DataJobsApiService } from '../../services'; + +import { + DataJob, + DataJobDetails, + DataJobExecutionsPage, + DataJobPage, + JOB_DEPLOYMENT_ID_REQ_PARAM, + JOB_DETAILS_DATA_KEY, + JOB_EXECUTIONS_DATA_KEY, + JOB_NAME_REQ_PARAM, + JOB_STATE_DATA_KEY, + JOBS_DATA_KEY, + TEAM_NAME_REQ_PARAM +} from '../../model'; + +import { FETCH_DATA_JOB, FETCH_DATA_JOB_EXECUTIONS, FETCH_DATA_JOBS, UPDATE_DATA_JOB } from '../actions'; + +import { DataJobsEffects } from './data-jobs.effects'; +import { + TASK_LOAD_JOB_DETAILS, + TASK_LOAD_JOB_EXECUTIONS, + TASK_LOAD_JOB_STATE, + TASK_UPDATE_JOB_DESCRIPTION, + TASK_UPDATE_JOB_STATUS +} from '../tasks'; + +describe('DataJobsEffects', () => { + let effects: DataJobsEffects; + + let actions$: Observable; + + let dataJobsApiServiceStub: jasmine.SpyObj; + let componentServiceStub: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + dataJobsApiServiceStub = jasmine.createSpyObj('dataJobsApiService', [ + 'getJobs', 'getJob', 'getJobDetails', 'getJobExecutions', 'updateDataJob', 'updateDataJobStatus' + ]); + componentServiceStub = jasmine.createSpyObj('componentService', ['getModel']); + + TestBed.configureTestingModule({ + providers: [ + { + provide: DataJobsApiService, + useValue: dataJobsApiServiceStub + }, + { + provide: ComponentService, + useValue: componentServiceStub + }, + provideMockActions(() => actions$), + DataJobsEffects + ] + }); + + effects = TestBed.inject(DataJobsEffects); + })); + + describe('Effects::', () => { + describe('|loadDataJobs$|', () => { + it('should verify will load data-jobs', marbles((m) => { + // Given + actions$ = m.cold('-a---------b', { + a: GenericAction.of(FETCH_DATA_JOBS, createModel().getComponentState()), + b: GenericAction.of(FETCH_DATA_JOBS, createModel(null, 4).getComponentState()) + }); + + let cntComponentServiceStub = 0; + componentServiceStub.getModel.and.callFake(() => { + cntComponentServiceStub++; + + if (cntComponentServiceStub === 1) { + return m.cold('--a', { a: createModel() }); + } + + return m.cold('--a', { a: createModel(null, 4) }); + }); + + const result = { + data: { + content: [{ jobName: 'job1' }, { jobName: 'job2' }, { jobName: 'job3' }], + totalPages: 0, + totalItems: 0 + } + } as ApolloQueryResult; + + let cntDataJobsApiServiceStub = 0; + dataJobsApiServiceStub.getJobs.and.callFake(() => { + cntDataJobsApiServiceStub++; + + if (cntDataJobsApiServiceStub === 1) { + return m.cold('-----a', { + a: result + }); + } + + return throwError(() => new Error('Something bad happened')); + }); + + const expected$ = m.cold('--------a----b', { + a: ComponentLoaded.of( + createModel() + .withData(JOBS_DATA_KEY, result.data) + .withStatusLoaded() + .getComponentState() + ), + b: ComponentFailed.of( + createModel(null, 4) + .withError(new Error('Something bad happened')) + .withStatusFailed() + .getComponentState() + ) + }); + + // When + const response$ = effects.loadDataJobs$; + + // Then + m.expect(response$).toBeObservable(expected$); + })); + }); + + describe('|loadDataJob$|', () => { + it('should verify will load data-job', marbles((m) => { + // Given + actions$ = m.cold('-a----------', { + a: GenericAction.of(FETCH_DATA_JOB, createModel().getComponentState()) + }); + + let cntComponentServiceStub = 0; + componentServiceStub.getModel.and.callFake(() => { + cntComponentServiceStub++; + + switch (cntComponentServiceStub) { + case 1: + return m.cold('--a', { a: createModel() }); + case 2: + return m.cold('---a', { a: createModel() }); + case 3: + return m.cold('---a', { + a: createModel( + new Map([getJobStateTuple()]), + 1, + LOADED + ) + }); + case 4: + return m.cold('---a', { + a: createModel( + new Map([getJobStateTuple(), getDataJobDetailsTuple()]), + 1, + LOADED + ) + }); + default: + return m.cold('---a', { + a: createModel( + new Map([ + getJobStateTuple(), + getDataJobDetailsTuple(), + [ + getExecutionsTuples()[0], + getExecutionsTuples()[1].content + ] + ]), + 1, + LOADED + ) + }); + } + }); + + dataJobsApiServiceStub.getJob.and.returnValue( + m.cold('--a', { + a: getJobStateTuple()[1] + }) + ); + + dataJobsApiServiceStub.getJobDetails.and.returnValue( + m.cold('----a', { + a: getDataJobDetailsTuple()[1] + }) + ); + + dataJobsApiServiceStub.getJobExecutions.and.returnValue( + m.cold('------a', { + a: getExecutionsTuples()[1] + }) + ); + + const expected$ = m.cold('--------a-b-c', { + a: ComponentLoaded.of( + createModel() + .clearError() + .withTask(TASK_LOAD_JOB_STATE) + .withData(JOB_STATE_DATA_KEY, getJobStateTuple()[1]) + .withStatusLoaded() + .getComponentState() + ), + b: ComponentUpdate.of( + createModel() + .clearError() + .withTask(TASK_LOAD_JOB_DETAILS) + .withData(JOB_STATE_DATA_KEY, getJobStateTuple()[1]) + .withData(JOB_DETAILS_DATA_KEY, getDataJobDetailsTuple()[1]) + .withStatusLoaded() + .getComponentState() + ), + c: ComponentUpdate.of( + createModel() + .clearError() + .withTask(TASK_LOAD_JOB_EXECUTIONS) + .withData(JOB_STATE_DATA_KEY, getJobStateTuple()[1]) + .withData(JOB_DETAILS_DATA_KEY, getDataJobDetailsTuple()[1]) + .withData(JOB_EXECUTIONS_DATA_KEY, getExecutionsTuples()[1].content) + .withStatusLoaded() + .getComponentState() + ) + }); + + // When + const response$ = effects.loadDataJob$; + + // Then + m.expect(response$).toBeObservable(expected$); + })); + }); + + describe('|loadDataJobExecutions$|', () => { + it('should verify will load data-job executions', marbles((m) => { + // Given + const genericAction = GenericAction.of( + FETCH_DATA_JOB_EXECUTIONS, + createModel().getComponentState(), + TASK_LOAD_JOB_EXECUTIONS + ); + actions$ = m.cold('-a----------', { + a: genericAction + }); + + let cntComponentServiceStub = 0; + componentServiceStub.getModel.and.callFake(() => { + cntComponentServiceStub++; + + switch (cntComponentServiceStub) { + case 1: + return m.cold('--a', { a: createModel() }); + case 2: + return m.cold('---a', { a: createModel() }); + default: + return m.cold('---a', { + a: createModel( + new Map([ + [ + getExecutionsTuples()[0], + getExecutionsTuples()[1].content + ] + ]), + 1, + LOADED + ) + }); + } + }); + + dataJobsApiServiceStub.getJobExecutions.and.returnValue( + m.cold('--a', { + a: getExecutionsTuples()[1] + }) + ); + + const expected$ = m.cold('--------a', { + a: ComponentLoaded.of( + createModel() + .clearError() + .withTask(genericAction.task) + .withData(JOB_EXECUTIONS_DATA_KEY, getExecutionsTuples()[1].content) + .withStatusLoaded() + .getComponentState() + ) + }); + + // When + const response$ = effects.loadDataJobExecutions$; + + // Then + m.expect(response$).toBeObservable(expected$); + })); + }); + + describe('|updateDataJob$|', () => { + it('should verify will update data-job description', marbles((m) => { + // Given + const genericAction1 = GenericAction.of( + UPDATE_DATA_JOB, + createModel(undefined, 1, LOADING).getComponentState(), + TASK_UPDATE_JOB_DESCRIPTION + ); + actions$ = m.cold('-a----------', { + a: genericAction1 + }); + + componentServiceStub.getModel.and.callFake(() => m.cold('---a', { + a: createModel(undefined, 1, LOADING) + })); + + dataJobsApiServiceStub.updateDataJob.and.returnValue( + m.cold('-----a', { + a: getDataJobDetailsTuple()[1] + }) + ); + + const expected$ = m.cold('---------a-', { + a: ComponentLoaded.of( + createModel(undefined, 1, LOADING) + .clearError() + .withTask(genericAction1.task) + .withData(JOB_DETAILS_DATA_KEY, undefined) + .withStatusLoaded() + .getComponentState() + ) + }); + + // When + const response$ = effects.updateDataJob$; + + // Then + m.expect(response$).toBeObservable(expected$); + })); + + it('should verify will update data-job status', marbles((m) => { + // Given + const genericAction1 = GenericAction.of( + UPDATE_DATA_JOB, + createModel(undefined, 1, LOADING).getComponentState(), + TASK_UPDATE_JOB_STATUS + ); + actions$ = m.cold('-a----------', { + a: genericAction1 + }); + + componentServiceStub.getModel.and.callFake(() => m.cold('---a', { + a: createModel(undefined, 1, LOADING) + })); + + dataJobsApiServiceStub.updateDataJobStatus.and.returnValue( + m.cold('-----a', { + a: ({ enabled: true }) + }) + ); + + const expected$ = m.cold('---------a-', { + a: ComponentLoaded.of( + createModel(undefined, 1, LOADING) + .clearError() + .withTask(genericAction1.task) + .withData(JOB_STATE_DATA_KEY, undefined) + .withStatusLoaded() + .getComponentState() + ) + }); + + // When + const response$ = effects.updateDataJob$; + + // Then + m.expect(response$).toBeObservable(expected$); + })); + + it('should verify will fail', marbles((m) => { + // Given + const genericAction1 = GenericAction.of( + UPDATE_DATA_JOB, + createModel(undefined, 1, LOADING).getComponentState(), + TASK_LOAD_JOB_STATE + ); + actions$ = m.cold('-a----------', { + a: genericAction1 + }); + + componentServiceStub.getModel.and.callFake(() => m.cold('---a', { + a: createModel() + })); + + const expected$ = m.cold('----a-', { + a: ComponentFailed.of( + createModel() + .withTask(genericAction1.task) + .withError(new Error('Unsupported ComponentModel update task')) + .withStatusFailed() + .getComponentState() + ) + }); + + // When + const response$ = effects.updateDataJob$; + + // Then + m.expect(response$).toBeObservable(expected$); + })); + }); + }); +}); + +const createModel = (data?: Map, + navigationId = 1, + status: StatusType = LOADING, + requestParams: Array<[string, any]> = []) => ComponentModel.of( + ComponentStateImpl.of({ + id: 'testComponent', + status, + data, + requestParams: new Map([ + [ + TEAM_NAME_REQ_PARAM, + 'aTeam' + ], + [ + JOB_NAME_REQ_PARAM, + 'aJob' + ], + [ + JOB_DEPLOYMENT_ID_REQ_PARAM, + 'aJobDeploymentId' + ], + ...requestParams + ]), + navigationId + }), + RouterState.of( + RouteState.empty(), + navigationId + ) +); + +const getJobStateTuple = () => [ + JOB_STATE_DATA_KEY, + { + jobName: 'aJob', + deployments: [], + config: { + schedule: { + scheduleCron: '5 5 5 5 *' + }, + description: 'aDesc', + team: 'aTeam', + logsUrl: 'http://url', + sourceUrl: 'http://urlsource' + } + } +] as [string, DataJob]; + +const getDataJobDetailsTuple = () => [ + JOB_DETAILS_DATA_KEY, + { + job_name: 'aJob', + team: 'aTeam', + description: 'aDesc', + config: { + schedule: { + schedule_cron: '5 5 5 5 *' + }, + contacts: { + notified_on_job_success: [], + notified_on_job_failure_user_error: [], + notified_on_job_failure_platform_error: [], + notified_on_job_deploy: [] + } + } + } +] as [string, DataJobDetails]; + +const getExecutionsTuples = () => [ + JOB_EXECUTIONS_DATA_KEY, + { + content: [ + { jobName: 'aJob', logsUrl: 'http://a1', id: 'a1' }, + { jobName: 'aJob', logsUrl: 'http://a2', id: 'a2' }, + { jobName: 'aJob', logsUrl: 'http://a3', id: 'a3' }, + { jobName: 'aJob', logsUrl: 'http://a4', id: 'a4' } + ], + totalPages: 1, + totalItems: 4 + } +] as [string, DataJobExecutionsPage]; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/data-jobs.effects.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/data-jobs.effects.ts new file mode 100644 index 0000000000..aaaf32fae7 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/data-jobs.effects.ts @@ -0,0 +1,335 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable ngrx/avoid-cyclic-effects */ + +import { Injectable } from '@angular/core'; + +import { merge, Observable, of, throwError } from 'rxjs'; +import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; + +import { Actions, createEffect, ofType } from '@ngrx/effects'; + +import { + ComponentFailed, + ComponentLoaded, + ComponentModel, + ComponentService, + ComponentState, + ComponentUpdate, + extractTaskFromIdentifier, + getModel, + getModelAndTask, + handleActionError, + LOADED, + StatusType +} from '@vdk/shared'; + +import { DataJobsApiService } from '../../services'; + +import { + DataJob, + DataJobDetails, + DataJobExecutionFilter, + DataJobExecutionOrder, + FILTER_REQ_PARAM, + JOB_DEPLOYMENT_ID_REQ_PARAM, + JOB_DETAILS_DATA_KEY, + JOB_DETAILS_REQ_PARAM, + JOB_EXECUTIONS_DATA_KEY, + JOB_NAME_REQ_PARAM, + JOB_STATE_DATA_KEY, + JOB_STATE_REQ_PARAM, + JOB_STATUS_REQ_PARAM, + JOBS_DATA_KEY, + ORDER_REQ_PARAM, + TEAM_NAME_REQ_PARAM +} from '../../model'; + +import { FETCH_DATA_JOB, FETCH_DATA_JOB_EXECUTIONS, FETCH_DATA_JOBS, UPDATE_DATA_JOB } from '../actions'; +import { + DataJobLoadTasks, + DataJobUpdateTasks, + TASK_LOAD_JOB_DETAILS, + TASK_LOAD_JOB_EXECUTIONS, + TASK_LOAD_JOB_STATE, + TASK_UPDATE_JOB_DESCRIPTION, + TASK_UPDATE_JOB_STATUS +} from '../tasks'; + +/** + * ** Effect for DataJobs. + */ +@Injectable() +export class DataJobsEffects { + /** + * ** Load DataJobs data. + */ + loadDataJobs$ = createEffect(() => this.actions$.pipe( + ofType(FETCH_DATA_JOBS), + getModel(this.componentService), + switchMap((model: ComponentModel) => this._loadDataJobs(model)) + )); + + loadDataJob$ = createEffect(() => this.actions$.pipe( + ofType(FETCH_DATA_JOB), + getModel(this.componentService), + switchMap((model) => + merge( + this._executeJobTask(model, TASK_LOAD_JOB_STATE), + this._executeJobTask(model, TASK_LOAD_JOB_DETAILS), + this._executeJobTask(model, TASK_LOAD_JOB_EXECUTIONS) + ) + ) + )); + + loadDataJobExecutions$ = createEffect(() => this.actions$.pipe( + ofType(FETCH_DATA_JOB_EXECUTIONS), + getModelAndTask(this.componentService), + switchMap(([model, task]) => this._loadDataJobExecutionsGraphQL(model, task)) + )); + + updateDataJob$ = createEffect(() => this.actions$.pipe( + ofType(UPDATE_DATA_JOB), + getModelAndTask(this.componentService), + switchMap(([model, task]) => this._updateJob(model, task)) // eslint-disable-line rxjs/no-unsafe-switchmap + )); + + /** + * ** Constructor. + */ + constructor(private readonly actions$: Actions, + private readonly dataJobsApiService: DataJobsApiService, + private readonly componentService: ComponentService) { + } + + private _loadDataJobs(componentModel: ComponentModel): Observable { + const componentState = componentModel.getComponentState(); + + return of(componentModel).pipe( + switchMap((model) => + this.dataJobsApiService.getJobs( + componentState.filter.criteria, + componentState.search, + componentState.page.page, + componentState.page.size + ).pipe( + map((response) => + model + .clearTask() + .clearError() + .withData(JOBS_DATA_KEY, response.data) + .withStatusLoaded() + .getComponentState() + ), + map((state) => ComponentLoaded.of(state)), + handleActionError(model) + ) + ) + ); + } + + private _executeJobTask(model: ComponentModel, + task: DataJobLoadTasks): Observable { + + switch (task) { + case TASK_LOAD_JOB_STATE: + return this._fetchJobData( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.dataJobsApiService.getJob.bind(this.dataJobsApiService), + model, + TASK_LOAD_JOB_STATE, + JOB_STATE_DATA_KEY + ); + case TASK_LOAD_JOB_DETAILS: + return this._fetchJobData( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.dataJobsApiService.getJobDetails.bind(this.dataJobsApiService), + model, + TASK_LOAD_JOB_DETAILS, + JOB_DETAILS_DATA_KEY + ); + case TASK_LOAD_JOB_EXECUTIONS: + return this._loadDataJobExecutionsGraphQL(model, TASK_LOAD_JOB_EXECUTIONS); + default: + return throwError(() => new Error('Unknown action task for Data Pipelines.')).pipe( + this._handleError(model, task) + ); + } + } + + private _fetchJobData(executor: ((param1: string, param2: string) => Observable), + componentModel: ComponentModel, + task: DataJobLoadTasks | string, + dataKey: string): Observable { + + return of(componentModel).pipe( + switchMap((model) => + executor( + componentModel.getComponentState().requestParams.get(TEAM_NAME_REQ_PARAM) as string, + componentModel.getComponentState().requestParams.get(JOB_NAME_REQ_PARAM) as string + ).pipe( + switchMap((data) => { + let obsoleteStatus: StatusType; + + return this._getLatestModel(model).pipe( + tap((newModel) => obsoleteStatus = newModel.status), + map((newModel) => + newModel + .clearError() + .withTask(task) + .withData(dataKey, data) + .withStatusLoaded() + .getComponentState() + ), + map((state) => + obsoleteStatus === LOADED + ? ComponentUpdate.of(state) + : ComponentLoaded.of(state) + ) + ); + }), + this._handleError(model, task) + ) + ) + ); + } + + private _updateJob(model: ComponentModel, + taskIdentifier: string): Observable { + + const task = extractTaskFromIdentifier(taskIdentifier); + const requestParams = model.getComponentState().requestParams; + + if (task === TASK_UPDATE_JOB_DESCRIPTION) { + const jobDetails: DataJobDetails = requestParams.get(JOB_DETAILS_REQ_PARAM); + + return this.dataJobsApiService + .updateDataJob( + requestParams.get(TEAM_NAME_REQ_PARAM) as string, + requestParams.get(JOB_NAME_REQ_PARAM) as string, + jobDetails + ) + .pipe( + map(() => ComponentLoaded.of( + model + .clearError() + .withTask(taskIdentifier) + .withData(JOB_DETAILS_DATA_KEY, jobDetails) + .withStatusLoaded() + .getComponentState() + ) + ), + this._handleError(model, taskIdentifier) + ); + } + + if (task === TASK_UPDATE_JOB_STATUS) { + const jobState: DataJob = model.getComponentState().requestParams.get(JOB_STATE_REQ_PARAM); + + return this.dataJobsApiService + .updateDataJobStatus( + requestParams.get(TEAM_NAME_REQ_PARAM) as string, + requestParams.get(JOB_NAME_REQ_PARAM) as string, + requestParams.get(JOB_DEPLOYMENT_ID_REQ_PARAM) as string, + requestParams.get(JOB_STATUS_REQ_PARAM) as boolean + ) + .pipe( + map(() => ComponentLoaded.of( + model + .clearError() + .withTask(taskIdentifier) + .withData(JOB_STATE_DATA_KEY, jobState) + .withStatusLoaded() + .getComponentState() + ) + ), + this._handleError(model, taskIdentifier) + ); + } + + const error = new Error('Unsupported action task for Data Pipelines, update Data Job.'); + + console.error(error); + + return of( + ComponentFailed.of( + model + .withTask(taskIdentifier) + .withError(error) + .withStatusFailed() + .getComponentState() + ) + ); + } + + private _loadDataJobExecutionsGraphQL(componentModel: ComponentModel, + task: DataJobLoadTasks | DataJobUpdateTasks | string = null): Observable { + + const componentState = componentModel.getComponentState(); + const requestParams = componentState.requestParams; + + return of(componentModel).pipe( + switchMap((model) => + this.dataJobsApiService + .getJobExecutions( + requestParams.get(TEAM_NAME_REQ_PARAM) as string, + requestParams.get(JOB_NAME_REQ_PARAM) as string, + true, + requestParams.get(FILTER_REQ_PARAM) as DataJobExecutionFilter, + requestParams.get(ORDER_REQ_PARAM) as DataJobExecutionOrder + ) + .pipe( + switchMap((response) => { + let obsoleteStatus: StatusType; + + return this._getLatestModel(model).pipe( + tap((newModel) => obsoleteStatus = newModel.status), + map((newModel) => + newModel + .clearError() + .withTask(task) + .withData(JOB_EXECUTIONS_DATA_KEY, response.content) + .withStatusLoaded() + .getComponentState() + ), + map((state) => // NOSONAR + obsoleteStatus === LOADED + ? ComponentUpdate.of(state) + : ComponentLoaded.of(state) + ) + ); + }), + this._handleError(model, task) + ) + ) + ); + } + + private _getLatestModel(componentModel: ComponentModel): Observable { + const componentState = componentModel.getComponentState(); + + return this.componentService + .getModel(componentState.id, componentState.routePathSegments, ['*']) + .pipe( + take(1) + ); + } + + private _handleError(obsoleteModel: ComponentModel, task: DataJobLoadTasks | string) { + return catchError>((error: unknown) => + this._getLatestModel(obsoleteModel).pipe( + map((newModel) => + newModel + .withTask(task) + .withError(error as Error) + .withStatusFailed() + .getComponentState() + ), + map((state) => ComponentFailed.of(state)) + ) + ); + } +} diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/index.ts new file mode 100644 index 0000000000..d16343392d --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/effects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-jobs.effects'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/tasks/data-job.tasks.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/tasks/data-job.tasks.ts new file mode 100644 index 0000000000..0ca1931c54 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/tasks/data-job.tasks.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// load tasks + +export const TASK_LOAD_JOB_STATE = 'load_job_state'; + +export const TASK_LOAD_JOB_DETAILS = 'load_job_details'; + +export const TASK_LOAD_JOB_EXECUTIONS = 'load_job_executions'; + +export type DataJobLoadTasks = typeof TASK_LOAD_JOB_STATE + | typeof TASK_LOAD_JOB_DETAILS + | typeof TASK_LOAD_JOB_EXECUTIONS; + +// update tasks + +export const TASK_UPDATE_JOB_DESCRIPTION = 'update_job_description'; + +export const TASK_UPDATE_JOB_STATUS = 'update_job_status'; + +export type DataJobUpdateTasks = typeof TASK_UPDATE_JOB_DESCRIPTION + | typeof TASK_UPDATE_JOB_STATUS; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/tasks/index.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/tasks/index.ts new file mode 100644 index 0000000000..a99c3244c8 --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/lib/state/tasks/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data-job.tasks'; diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/public-api.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/public-api.ts new file mode 100644 index 0000000000..3d9dea2aaf --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/public-api.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Public API Surface of data-pipelines + */ + +export * from './lib/data-pipelines.module'; + +// Components / Pages +export * from './lib/components/public-api'; + +// Services +export * from './lib/services/public-api'; + +// Models +export * from './lib/model/public-api'; + +// Shared diff --git a/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/test.ts b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/test.ts new file mode 100644 index 0000000000..c1e9eebe8a --- /dev/null +++ b/projects/frontend/data-pipelines/gui/projects/data-pipelines/src/test.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2023 VMware, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js'; +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; + +declare const require: { + context(path: string, deep?: boolean, filter?: RegExp): { + keys(): string[]; + (id: string): T; + }; +}; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { + teardown: { destroyAfterEach: false } + } +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().forEach(context);