import { get } from 'lodash'; import { $q } from 'ngimport'; import React from 'react'; import type { Subscription } from 'rxjs'; import type { Application } from '../../application'; import type { IDefaultTagFilterConfig } from '../../application/config/defaultTagFilter/DefaultTagFilterConfig'; import { CreatePipeline } from '../config/CreatePipeline'; import { CreatePipelineButton } from '../create/CreatePipelineButton'; import type { IExecution, IPipeline, IPipelineCommand } from '../../domain'; import { ExecutionGroups } from './executionGroup/ExecutionGroups'; import { ExecutionFilters } from '../filter/ExecutionFilters'; import { ExecutionFilterService } from '../filter/executionFilter.service'; import type { IFilterTag, ISortFilter } from '../../filterModel'; import { FilterCollapse, FilterTags } from '../../filterModel'; import { ManualExecutionModal } from '../manualExecution'; import { Overridable } from '../../overrideRegistry'; import { Tooltip } from '../../presentation/Tooltip'; import { ReactInjector } from '../../reactShims'; import { SchedulerFactory } from '../../scheduler'; import type { IScheduler } from '../../scheduler/SchedulerFactory'; import { ExecutionState } from '../../state'; import { logger } from '../../utils'; import type { IRetryablePromise } from '../../utils/retryablePromise'; import { Spinner } from '../../widgets/spinners/Spinner'; import './executions.less'; export interface IExecutionsProps { app: Application; } export interface IExecutionsState { initializationError?: boolean; filtersExpanded: boolean; loading: boolean; poll: IRetryablePromise; sortFilter: ISortFilter; tags: IFilterTag[]; triggeringExecution: boolean; reloadingForFilters: boolean; } // This Set ensures we only forward once from .executions to .executionDetails for an aged out execution const forwardedExecutions = new Set(); // This ensures we only forward to permalink on landing, not on future refreshes let disableForwarding = false; @Overridable('PipelineExecutions') export class Executions extends React.Component { private executionsRefreshUnsubscribe: Function; private groupsUpdatedSubscription: Subscription; private insightFilterStateModel = ReactInjector.insightFilterStateModel; private activeRefresher: IScheduler; private filterCountOptions = [1, 2, 5, 10, 20, 30, 40, 50, 100, 200]; constructor(props: IExecutionsProps) { super(props); this.state = { filtersExpanded: this.insightFilterStateModel.filtersExpanded, loading: true, poll: null, sortFilter: ExecutionState.filterModel.asFilterModel.sortFilter, tags: [], triggeringExecution: false, reloadingForFilters: false, }; } private setReloadingForFilters = (reloadingForFilters: boolean) => { if (this.state.reloadingForFilters !== reloadingForFilters) { this.setState({ reloadingForFilters }); } }; private loadDefaultFilters = (): void => { const defaultTags = this.props.app.attributes.defaultFilteredTags; if (defaultTags != null) { this.props.app.attributes.defaultFilteredTags.forEach((defaultTag: IDefaultTagFilterConfig) => { ExecutionState.filterModel.asFilterModel.sortFilter.tags[`${defaultTag.tagName}:${defaultTag.tagValue}`] = true; }); this.updateExecutionGroups(true); } }; private clearFilters = (): void => { ExecutionFilterService.clearFilters(); this.updateExecutionGroups(true); }; private forceUpdateExecutionGroups = (): void => { this.updateExecutionGroups(true); }; private updateExecutionGroups(reload?: boolean): void { this.normalizeExecutionNames(); const { app } = this.props; // updateExecutionGroups is debounced by 25ms, so we need to delay setting the loading flags a bit if (reload) { this.setReloadingForFilters(true); app.executions.refresh(true).then(() => { ExecutionFilterService.updateExecutionGroups(app); setTimeout(() => this.setReloadingForFilters(false), 50); }); } else { ExecutionFilterService.updateExecutionGroups(app); this.groupsUpdated(); setTimeout(() => { this.setState({ loading: false }); }, 50); } } private groupsUpdated(): void { const newTags = ExecutionState.filterModel.asFilterModel.tags; const currentTags = this.state.tags; const areEqual = (t1: IFilterTag, t2: IFilterTag) => t1.key === t2.key && t1.label === t2.label && t1.value === t2.value; const tagsChanged = newTags.length !== currentTags.length || newTags.some((t1) => !currentTags.some((t2) => areEqual(t1, t2))); if (tagsChanged) { this.setState({ tags: newTags }); } } private dataInitializationFailure(): void { this.setState({ loading: false, initializationError: true }); } private normalizeExecutionNames(): void { const { app } = this.props; if (app.executions.loadFailure) { this.dataInitializationFailure(); } const executions = app.executions.data || []; const configurations: any[] = app.pipelineConfigs.data || []; executions.forEach((execution: any) => { if (execution.pipelineConfigId) { const configMatch = configurations.find((c: any) => c.id === execution.pipelineConfigId); if (configMatch) { execution.name = configMatch.name; } } }); } private expand = (): void => { logger.log({ category: 'Pipelines', action: 'Expand All' }); ExecutionState.filterModel.expandSubject.next(true); }; private collapse = (): void => { logger.log({ category: 'Pipelines', action: 'Collapse All' }); ExecutionState.filterModel.expandSubject.next(false); }; private startPipeline(command: IPipelineCommand): PromiseLike { const { executionService } = ReactInjector; this.setState({ triggeringExecution: true }); return executionService .startAndMonitorPipeline(this.props.app, command.pipelineName, command.trigger) .then((monitor) => { this.setState({ poll: monitor }); return monitor.promise; }) .finally(() => { this.setState({ triggeringExecution: false }); }); } private startManualExecutionClicked = (): void => { this.triggerPipeline(); }; private triggerPipeline(pipeline: IPipeline = null): void { logger.log({ category: 'Pipelines', action: 'Trigger Pipeline (top level)' }); ManualExecutionModal.show({ pipeline: pipeline, application: this.props.app, }) .then((command) => { this.startPipeline(command); this.clearManualExecutionParam(); }) .catch(() => this.clearManualExecutionParam()); } private clearManualExecutionParam(): void { ReactInjector.$state.go('.', { startManualExecution: null }, { inherit: true, location: 'replace' }); } private handleAgedOutExecutions(executionId: string, forwardToPermalink: boolean): void { const { $state, executionService } = ReactInjector; if (forwardToPermalink && executionId && !forwardedExecutions.has(executionId)) { // We only want to forward to permalink on initial load executionService.getExecution(executionId).then(() => { const detailsState = $state.current.name.replace('executions.execution', 'executionDetails.execution'); const { stage, step, details } = $state.params; forwardedExecutions.add(executionId); $state.go(detailsState, { executionId, stage, step, details }); }); } else { // Handles the case where we already forwarded once and user navigated back, so do not forward again. $state.go('.^'); } } public componentDidMount(): void { const { app } = this.props; if (ExecutionState.filterModel.mostRecentApplication !== app.name) { ExecutionState.filterModel.asFilterModel.groups = []; ExecutionState.filterModel.mostRecentApplication = app.name; } if (app.notFound || app.hasError) { return; } app.setActiveState(app.executions); app.executions.activate(); app.pipelineConfigs.activate(); this.activeRefresher = SchedulerFactory.createScheduler(5000); this.activeRefresher.subscribe(() => { app.getDataSource('runningExecutions').refresh(); }); this.groupsUpdatedSubscription = ExecutionFilterService.groupsUpdatedStream.subscribe(() => this.groupsUpdated()); this.executionsRefreshUnsubscribe = app.executions.onRefresh( null, () => { this.normalizeExecutionNames(); // if an execution was selected but is no longer present, navigate up const { $state } = ReactInjector; if ($state.params.executionId) { const executions: IExecution[] = app.executions.data; if (executions.every((e) => e.id !== $state.params.executionId)) { this.handleAgedOutExecutions($state.params.executionId, !disableForwarding); } } // After the very first refresh interval (landing), we do not want to forward the user to the permalink disableForwarding = true; }, () => this.dataInitializationFailure(), ); this.loadDefaultFilters(); $q.all([app.executions.ready(), app.pipelineConfigs.ready()]).then(() => { this.updateExecutionGroups(); const nameOrIdToStart = ReactInjector.$stateParams.startManualExecution; if (nameOrIdToStart) { const toStart = app.pipelineConfigs.data.find((p: IPipeline) => [p.id, p.name].includes(nameOrIdToStart)); if (toStart) { this.triggerPipeline(toStart); } else { this.clearManualExecutionParam(); } } }); } public componentWillUnmount(): void { const { app } = this.props; app.setActiveState(); app.executions.deactivate(); app.pipelineConfigs.deactivate(); this.executionsRefreshUnsubscribe(); this.groupsUpdatedSubscription.unsubscribe(); this.activeRefresher && this.activeRefresher.unsubscribe(); this.state.poll && this.state.poll.cancel(); } private toggleFilters = (): void => { const newState = !this.state.filtersExpanded; this.setState({ filtersExpanded: newState }); this.insightFilterStateModel.pinFilters(newState); }; private groupByChanged = (event: React.ChangeEvent): void => { const value = event.target.value; logger.log({ category: 'Pipelines', action: 'Group By', data: { label: value } }); this.state.sortFilter.groupBy = value; this.updateExecutionGroups(); }; private showCountChanged = (event: React.ChangeEvent): void => { const value = event.target.value; this.state.sortFilter.count = parseInt(value, 10); logger.log({ category: 'Pipelines', action: 'Change Count', data: { label: value } }); this.updateExecutionGroups(true); }; private showDurationsChanged = (event: React.ChangeEvent): void => { const checked = event.target.checked; // TODO: Since we treat sortFilter like a store, we can force the setState for now // but we should eventually convert all the sortFilters to be a valid redux // (or similar) store. this.state.sortFilter.showDurations = checked; this.setState({ sortFilter: this.state.sortFilter }); logger.log({ category: 'Pipelines', action: 'Toggle Durations', data: { label: checked.toString() } }); }; public render(): React.ReactElement { const { app } = this.props; const { filtersExpanded, loading, sortFilter, tags, triggeringExecution, reloadingForFilters } = this.state; const hasPipelines = !!(get(app, 'executions.data', []).length || get(app, 'pipelineConfigs.data', []).length); if (!app.notFound && !app.hasError) { if (!hasPipelines && !loading) { return (

No pipelines configured for this application.

); } return (
{!loading && (
)}
{filtersExpanded && (
{!loading && ( )}
)}
{!loading && (
{sortFilter.groupBy && ( )}
executions per pipeline
)} {loading && (
)} {reloadingForFilters && (
)} {!loading && !hasPipelines && (

No pipelines configured for this application.

)} {app.executions.loadFailure && (

There was an error loading executions. We'll try again shortly.

)} {!loading && hasPipelines && }
); } return null; } }