diff --git a/app/cdap/components/NamespaceAdmin/SourceControlManagement/index.tsx b/app/cdap/components/NamespaceAdmin/SourceControlManagement/index.tsx index 938051ae986..2661504d494 100644 --- a/app/cdap/components/NamespaceAdmin/SourceControlManagement/index.tsx +++ b/app/cdap/components/NamespaceAdmin/SourceControlManagement/index.tsx @@ -27,7 +27,6 @@ import TableBody from 'components/shared/Table/TableBody'; import { useSelector } from 'react-redux'; import ActionsPopover from 'components/shared/ActionsPopover'; import { UnlinkSourceControlModal } from './UnlinkSourceControlModal'; -import StyledPasswordWrapper from 'components/AbstractWidget/FormInputs/Password'; import { ISourceControlManagementConfig } from './types'; import SourceControlManagementForm from './SourceControlManagementForm'; import PrimaryTextButton from 'components/shared/Buttons/PrimaryTextButton'; @@ -35,6 +34,7 @@ import { getCurrentNamespace } from 'services/NamespaceStore'; import { getSourceControlManagement } from '../store/ActionCreator'; import Alert from 'components/shared/Alert'; import ButtonLoadingHoc from 'components/shared/Buttons/ButtonLoadingHoc'; +import { useHistory } from 'react-router'; const PrimaryTextLoadingButton = ButtonLoadingHoc(PrimaryTextButton); @@ -58,6 +58,8 @@ export const SourceControlManagement = () => { const sourceControlManagementConfig: ISourceControlManagementConfig = useSelector( (state) => state.sourceControlManagementConfig ); + const history = useHistory(); + const toggleForm = () => { setIsFormOpen(!isFormOpen); }; diff --git a/app/cdap/components/ResourceCenterEntity/PullPipelineWizard.tsx b/app/cdap/components/ResourceCenterEntity/PullPipelineWizard.tsx index 390d3c99184..0a13fabbc27 100644 --- a/app/cdap/components/ResourceCenterEntity/PullPipelineWizard.tsx +++ b/app/cdap/components/ResourceCenterEntity/PullPipelineWizard.tsx @@ -80,7 +80,7 @@ export const PullPipelineWizard = ({ isOpen, error, dispatch }: IPullPipelineWiz toggle={() => dispatch({ type: 'TOGGLE_MODAL' })} > - + diff --git a/app/cdap/components/SourceControlManagement/LocalPipelineListView/PipelineTable.tsx b/app/cdap/components/SourceControlManagement/LocalPipelineListView/PipelineTable.tsx index a72dd70ec37..f742fea5c39 100644 --- a/app/cdap/components/SourceControlManagement/LocalPipelineListView/PipelineTable.tsx +++ b/app/cdap/components/SourceControlManagement/LocalPipelineListView/PipelineTable.tsx @@ -14,11 +14,22 @@ * the License. */ -import React from 'react'; -import { Checkbox, Table, TableBody, TableCell, TableRow, TableHead } from '@material-ui/core'; +import React, { useState } from 'react'; +import { + Checkbox, + Table, + TableBody, + TableCell, + TableRow, + TableHead, + TableSortLabel, + TablePagination, +} from '@material-ui/core'; import InfoIcon from '@material-ui/icons/Info'; +import CheckCircleIcon from '@material-ui/icons/CheckCircle'; +import ErrorIcon from '@material-ui/icons/Error'; import { setSelectedPipelines } from '../store/ActionCreator'; -import { IRepositoryPipeline } from '../types'; +import { IRepositoryPipeline, TSyncStatusFilter } from '../types'; import T from 'i18n-react'; import StatusButton from 'components/StatusButton'; import { SUPPORT } from 'components/StatusButton/constants'; @@ -29,8 +40,12 @@ import { StatusCell, StyledFixedWidthCell, StyledPopover, + SyncStatusWrapper, } from '../styles'; import { timeInstantToString } from 'services/DataFormatter'; +import { compareSyncStatus, filterOnSyncStatus, stableSort } from '../helpers'; +import LoadingSVG from 'components/shared/LoadingSVG'; +import { green, red } from '@material-ui/core/colors'; const PREFIX = 'features.SourceControlManagement.table'; @@ -38,18 +53,40 @@ interface IRepositoryPipelineTableProps { localPipelines: IRepositoryPipeline[]; selectedPipelines: string[]; showFailedOnly: boolean; - enableMultipleSelection?: boolean; + multiPushEnabled?: boolean; disabled?: boolean; + syncStatusFilter?: TSyncStatusFilter; } export const LocalPipelineTable = ({ localPipelines, selectedPipelines, showFailedOnly, - enableMultipleSelection = false, + multiPushEnabled = false, disabled = false, + syncStatusFilter = 'all', }: IRepositoryPipelineTableProps) => { + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const isSelected = (name: string) => selectedPipelines.indexOf(name) !== -1; + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + const syncStatusComparator = (a: IRepositoryPipeline, b: IRepositoryPipeline) => { + return sortOrder === 'desc' ? compareSyncStatus(a, b) : -compareSyncStatus(a, b); + }; + + const filteredPipelines = filterOnSyncStatus(localPipelines, syncStatusFilter); + const displayedPipelines = stableSort(filteredPipelines, syncStatusComparator).slice( + page * rowsPerPage, + (page + 1) * rowsPerPage + ); + const displayedPipelineNames = displayedPipelines.map((pipeline) => pipeline.name); + + const selectedPipelinesSet = new Set(selectedPipelines); + const isAllDisplayedPipelinesSelected = displayedPipelineNames.reduce((acc, pipelineName) => { + return acc && selectedPipelinesSet.has(pipelineName); + }, true); const handleSelectAllClick = (event: React.ChangeEvent) => { if (disabled) { @@ -57,8 +94,7 @@ export const LocalPipelineTable = ({ } if (event.target.checked) { - const allSelected = localPipelines.map((pipeline) => pipeline.name); - setSelectedPipelines(allSelected); + setSelectedPipelines(displayedPipelineNames); return; } setSelectedPipelines([]); @@ -69,7 +105,7 @@ export const LocalPipelineTable = ({ return; } - if (enableMultipleSelection) { + if (multiPushEnabled) { handleMultipleSelection(name); return; } @@ -98,19 +134,31 @@ export const LocalPipelineTable = ({ setSelectedPipelines(newSelected); }; + const handleSort = () => { + const isAsc = sortOrder === 'asc'; + setSortOrder(isAsc ? 'desc' : 'asc'); + }; + + const handleChangePage = (event, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + return ( - {enableMultipleSelection && ( + {multiPushEnabled && ( 0 && selectedPipelines.length < localPipelines.length - } - checked={selectedPipelines.length === localPipelines.length} + indeterminate={selectedPipelines.length > 0 && !isAllDisplayedPipelinesSelected} + checked={isAllDisplayedPipelinesSelected} onChange={handleSelectAllClick} disabled={disabled} /> @@ -127,10 +175,27 @@ export const LocalPipelineTable = ({ + {multiPushEnabled && ( + + + {T.translate(`${PREFIX}.gitSyncStatus`)} + + + )} + {!multiPushEnabled && ( + +
+ {T.translate(`${PREFIX}.gitStatus`)} + } showOn="Hover"> + {T.translate(`${PREFIX}.gitStatusHelperText`)} + +
+
+ )}
- {localPipelines.map((pipeline: IRepositoryPipeline) => { + {displayedPipelines.map((pipeline: IRepositoryPipeline) => { if (showFailedOnly && !pipeline.error) { // only render pipelines that failed to push return; @@ -173,11 +238,47 @@ export const LocalPipelineTable = ({ {pipeline.fileHash ? T.translate(`${PREFIX}.connected`) : '--'} + {multiPushEnabled && ( + + {pipeline.syncStatus === undefined || + pipeline.syncStatus === 'not_available' ? ( + + + {T.translate(`${PREFIX}.gitSyncStatusFetching`)} + + ) : pipeline.syncStatus === 'not_connected' || + pipeline.syncStatus === 'out_of_sync' ? ( + + {' '} + {T.translate(`${PREFIX}.gitSyncStatusUnsynced`)} + + ) : ( + + + {T.translate(`${PREFIX}.gitSyncStatusSynced`)} + + )} + + )} + {!multiPushEnabled && ( + + {pipeline.fileHash ? T.translate(`${PREFIX}.connected`) : '--'} + + )} ); })}
+
); }; diff --git a/app/cdap/components/SourceControlManagement/LocalPipelineListView/index.tsx b/app/cdap/components/SourceControlManagement/LocalPipelineListView/index.tsx index 1e0593b5aab..27fff02a4a5 100644 --- a/app/cdap/components/SourceControlManagement/LocalPipelineListView/index.tsx +++ b/app/cdap/components/SourceControlManagement/LocalPipelineListView/index.tsx @@ -15,7 +15,7 @@ */ import PrimaryContainedButton from 'components/shared/Buttons/PrimaryContainedButton'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { getCurrentNamespace } from 'services/NamespaceStore'; import { @@ -24,11 +24,14 @@ import { getNamespacePipelineList, pushMultipleSelectedPipelines, pushSelectedPipelines, - reset, + refetchAllPipelines, resetPushStatus, setLoadingMessage, setLocalPipelines, setNameFilter, + setSyncStatusFilter, + setSyncStatusOfAllPipelines, + setSyncStatusOfRemotePipelines, toggleCommitModal, toggleShowFailedOnly, } from '../store/ActionCreator'; @@ -40,11 +43,17 @@ import cloneDeep from 'lodash/cloneDeep'; import PrimaryTextButton from 'components/shared/Buttons/PrimaryTextButton'; import { LocalPipelineTable } from './PipelineTable'; import { useOnUnmount } from 'services/react/customHooks/useOnUnmount'; -import { FailStatusDiv, PipelineListContainer, StyledSelectionStatusDiv } from '../styles'; +import { + FailStatusDiv, + FiltersAndStatusWrapper, + PipelineListContainer, + StyledSelectionStatusDiv, +} from '../styles'; import { IListResponse, IOperationMetaResponse, IOperationRun } from '../types'; import { useFeatureFlagDefaultFalse } from 'services/react/customHooks/useFeatureFlag'; import { parseOperationResource } from '../helpers'; import { OperationAlert } from '../OperationAlert'; +import { SyncStatusFilters } from '../SyncStatusFilters'; const PREFIX = 'features.SourceControlManagement.push'; @@ -57,6 +66,7 @@ export const LocalPipelineListView = () => { commitModalOpen, loadingMessage, showFailedOnly, + syncStatusFilter, } = useSelector(({ push }) => push); const { running: isAnOperationRunning, operation } = useSelector( @@ -80,8 +90,6 @@ export const LocalPipelineListView = () => { } }, []); - useOnUnmount(() => reset()); - const onPushSubmit = (commitMessage: string) => { resetPushStatus(); const pushedPipelines = cloneDeep(localPipelines); @@ -108,6 +116,8 @@ export const LocalPipelineListView = () => { }, complete() { setLoadingMessage(null); + refetchAllPipelines(); + setSyncStatusOfAllPipelines(); }, }); @@ -131,6 +141,8 @@ export const LocalPipelineListView = () => { }, complete() { setLoadingMessage(null); + refetchAllPipelines(); + setSyncStatusOfAllPipelines(); }, }); }; @@ -143,8 +155,9 @@ export const LocalPipelineListView = () => { localPipelines={localPipelines} selectedPipelines={selectedPipelines} showFailedOnly={showFailedOnly} - enableMultipleSelection={multiPushEnabled} + multiPushEnabled={multiPushEnabled} disabled={isAnOperationRunning} + syncStatusFilter={syncStatusFilter} /> { return ( + {operation && multiPushEnabled && } {operation && multiPushEnabled && } {selectedPipelines.length > 0 && ( @@ -193,6 +207,43 @@ export const LocalPipelineListView = () => { )} )} + + {selectedPipelines.length > 0 && ( + +
+ {T.translate(`${PREFIX}.pipelinesSelected`, { + selected: selectedPipelines.length, + total: localPipelines.length, + })} +
+ {!multiPushEnabled && pushFailedCount > 0 && ( + <> + + {pushFailedCount === 1 + ? T.translate(`${PREFIX}.pipelinePushedFail`) + : T.translate(`${PREFIX}.pipelinesPushedFail`, { + count: pushFailedCount.toString(), + })} + + + {showFailedOnly + ? T.translate('commons.showAll') + : T.translate('commons.showFailed')} + + + )} + {multiPushEnabled && pushFailedCount > 0 && ( + {T.translate(`${PREFIX}.pipelinesPushedFailMulti`)} + )} +
+ )} + {multiPushEnabled && ( + + )} +
{ready ? LocalPipelineTableComp() : } { + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const isSelected = (name: string) => selectedPipelines.indexOf(name) !== -1; + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + const syncStatusComparator = (a: IRepositoryPipeline, b: IRepositoryPipeline) => { + return sortOrder === 'desc' ? compareSyncStatus(a, b) : -compareSyncStatus(a, b); + }; + + const filteredPipelines = filterOnSyncStatus(remotePipelines, syncStatusFilter); + const displayedPipelines = stableSort(filteredPipelines, syncStatusComparator).slice( + page * rowsPerPage, + (page + 1) * rowsPerPage + ); + const displayedPipelineNames = displayedPipelines.map((pipeline) => pipeline.name); + + const selectedPipelinesSet = new Set(selectedPipelines); + const isAllDisplayedPipelinesSelected = displayedPipelineNames.reduce((acc, pipelineName) => { + return acc && selectedPipelinesSet.has(pipelineName); + }, true); const handleClick = (event: React.MouseEvent, name: string) => { if (disabled) { return; } - if (enableMultipleSelection) { + if (multiPullEnabled) { handleMultipleSelection(name); return; } @@ -83,27 +125,37 @@ export const RemotePipelineTable = ({ } if (event.target.checked) { - const allSelected = remotePipelines.map((pipeline) => pipeline.name); - setSelectedRemotePipelines(allSelected); + setSelectedRemotePipelines(displayedPipelineNames); return; } setSelectedRemotePipelines([]); }; + const handleSort = () => { + const isAsc = sortOrder === 'asc'; + setSortOrder(isAsc ? 'desc' : 'asc'); + }; + + const handleChangePage = (event, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + return ( - {enableMultipleSelection && ( + {multiPullEnabled && ( 0 && - selectedPipelines.length < remotePipelines.length - } - checked={selectedPipelines.length === remotePipelines.length} + indeterminate={selectedPipelines.length > 0 && !isAllDisplayedPipelinesSelected} + checked={isAllDisplayedPipelinesSelected} onChange={handleSelectAllClick} disabled={disabled} /> @@ -111,10 +163,17 @@ export const RemotePipelineTable = ({ {T.translate(`${PREFIX}.pipelineName`)} + {multiPullEnabled && ( + + + {T.translate(`${PREFIX}.gitSyncStatus`)} + + + )} - {remotePipelines.map((pipeline: IRepositoryPipeline) => { + {displayedPipelines.map((pipeline: IRepositoryPipeline) => { if (showFailedOnly && !pipeline.error) { // only render pipelines that failed to pull return; @@ -154,11 +213,42 @@ export const RemotePipelineTable = ({ )} {pipeline.name} + {multiPullEnabled && ( + + {pipeline.syncStatus === undefined || + pipeline.syncStatus === 'not_available' ? ( + + + {T.translate(`${PREFIX}.gitSyncStatusFetching`)} + + ) : pipeline.syncStatus === 'not_connected' || + pipeline.syncStatus === 'out_of_sync' ? ( + + {' '} + {T.translate(`${PREFIX}.gitSyncStatusUnsynced`)} + + ) : ( + + + {T.translate(`${PREFIX}.gitSyncStatusSynced`)} + + )} + + )} ); })}
+
); }; diff --git a/app/cdap/components/SourceControlManagement/RemotePipelineListView/index.tsx b/app/cdap/components/SourceControlManagement/RemotePipelineListView/index.tsx index bbeeadfbdda..2351771be72 100644 --- a/app/cdap/components/SourceControlManagement/RemotePipelineListView/index.tsx +++ b/app/cdap/components/SourceControlManagement/RemotePipelineListView/index.tsx @@ -14,12 +14,17 @@ * the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import T from 'i18n-react'; import { useSelector } from 'react-redux'; import cloneDeep from 'lodash/cloneDeep'; import { SearchBox } from '../SearchBox'; -import { FailStatusDiv, PipelineListContainer, StyledSelectionStatusDiv } from '../styles'; +import { + FailStatusDiv, + FiltersAndStatusWrapper, + PipelineListContainer, + StyledSelectionStatusDiv, +} from '../styles'; import { RemotePipelineTable } from './RemotePipelineTable'; import { countPullFailedPipelines, @@ -27,20 +32,21 @@ import { pullAndDeploySelectedRemotePipelines, resetPullStatus, setPullViewErrorMsg, - resetRemote, setRemoteLoadingMessage, setRemoteNameFilter, setRemotePipelines, toggleRemoteShowFailedOnly, pullAndDeployMultipleSelectedRemotePipelines, fetchLatestOperation, + setRemoteSyncStatusFilter, + setSyncStatusOfAllPipelines, + refetchAllPipelines, } from '../store/ActionCreator'; import { LoadingAppLevel } from 'components/shared/LoadingAppLevel'; import { getCurrentNamespace } from 'services/NamespaceStore'; import LoadingSVGCentered from 'components/shared/LoadingSVGCentered'; import PrimaryTextButton from 'components/shared/Buttons/PrimaryTextButton'; import PrimaryContainedButton from 'components/shared/Buttons/PrimaryContainedButton'; -import { useOnUnmount } from 'services/react/customHooks/useOnUnmount'; import { getHydratorUrl } from 'services/UiUtils/UrlGenerator'; import { SUPPORT } from 'components/StatusButton/constants'; import { IListResponse, IOperationMetaResponse, IOperationRun } from '../types'; @@ -48,14 +54,19 @@ import Alert from 'components/shared/Alert'; import { useFeatureFlagDefaultFalse } from 'services/react/customHooks/useFeatureFlag'; import { parseOperationResource } from '../helpers'; import { OperationAlert } from '../OperationAlert'; +import { SyncStatusFilters } from '../SyncStatusFilters'; const PREFIX = 'features.SourceControlManagement.pull'; interface IRemotePipelineListViewProps { redirectOnSubmit?: boolean; + singlePipelineMode?: boolean; } -export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineListViewProps) => { +export const RemotePipelineListView = ({ + redirectOnSubmit, + singlePipelineMode, +}: IRemotePipelineListViewProps) => { const { ready, remotePipelines, @@ -64,15 +75,16 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList loadingMessage, showFailedOnly, pullViewErrorMsg, + syncStatusFilter, } = useSelector(({ pull }) => pull); const { running: isAnOperationRunning, operation } = useSelector( ({ operationRun }) => operationRun ); - const multiPullEnabled = useFeatureFlagDefaultFalse( - 'source.control.management.multi.app.enabled' - ); + const multiPullEnabled = + useFeatureFlagDefaultFalse('source.control.management.multi.app.enabled') && + !singlePipelineMode; const pullFailedCount = countPullFailedPipelines(); useEffect(() => { @@ -87,8 +99,6 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList } }, []); - useOnUnmount(() => resetRemote()); - const filteredPipelines = remotePipelines.filter((pipeline) => pipeline.name.toLowerCase().includes(nameFilter.toLowerCase()) ); @@ -115,6 +125,8 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList }, complete() { setRemoteLoadingMessage(null); + refetchAllPipelines(); + setSyncStatusOfAllPipelines(); }, }); @@ -144,6 +156,8 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList }, complete() { setRemoteLoadingMessage(null); + refetchAllPipelines(); + setSyncStatusOfAllPipelines(); }, }); }; @@ -156,8 +170,9 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList remotePipelines={filteredPipelines} selectedPipelines={selectedPipelines} showFailedOnly={showFailedOnly} - enableMultipleSelection={multiPullEnabled} + multiPullEnabled={multiPullEnabled} disabled={isAnOperationRunning} + syncStatusFilter={syncStatusFilter} /> setPullViewErrorMsg()} /> - {operation && multiPullEnabled && } - {selectedPipelines.length > 0 && ( - -
- {T.translate(`${PREFIX}.pipelinesSelected`, { - selected: selectedPipelines.length, - total: remotePipelines.length, - })} -
- {pullFailedCount > 0 && ( - <> - - {pullFailedCount === 1 - ? T.translate(`${PREFIX}.pipelinePulledFail`) - : T.translate(`${PREFIX}.pipelinesPulledFail`, { - count: pullFailedCount.toString(), - })} - - - {showFailedOnly - ? T.translate('commons.showAll') - : T.translate('commons.showFailed')} - - - )} -
- )} + + + {selectedPipelines.length > 0 && ( + +
+ {T.translate(`${PREFIX}.pipelinesSelected`, { + selected: selectedPipelines.length, + total: remotePipelines.length, + })} +
+ {pullFailedCount > 0 && ( + <> + + {pullFailedCount === 1 + ? T.translate(`${PREFIX}.pipelinePulledFail`) + : T.translate(`${PREFIX}.pipelinesPulledFail`, { + count: pullFailedCount.toString(), + })} + + + {showFailedOnly + ? T.translate('commons.showAll') + : T.translate('commons.showFailed')} + + + )} +
+ )} + {multiPullEnabled && ( + + )} +
{ready ? RemotePipelineTableComp() : }
void; +} + +export const SyncStatusFilters = ({ + syncStatusFilter, + setSyncStatusFilter, +}: ISyncStatusFiltersProps) => { + const setFilter = (filterVal: TSyncStatusFilter) => () => setSyncStatusFilter(filterVal); + + return ( + + {T.translate(`${PREFIX}.filtersLabel`)}: + + + + + + + ); +}; diff --git a/app/cdap/components/SourceControlManagement/SyncTabs.tsx b/app/cdap/components/SourceControlManagement/SyncTabs.tsx index 833f4ba2fc5..91a937baa67 100644 --- a/app/cdap/components/SourceControlManagement/SyncTabs.tsx +++ b/app/cdap/components/SourceControlManagement/SyncTabs.tsx @@ -22,7 +22,11 @@ import styled from 'styled-components'; import T from 'i18n-react'; import { RemotePipelineListView } from './RemotePipelineListView'; import { FeatureProvider } from 'services/react/providers/featureFlagProvider'; -import { getNamespacePipelineList, getRemotePipelineList } from './store/ActionCreator'; +import { + getNamespacePipelineList, + getRemotePipelineList, + setSyncStatusOfAllPipelines, +} from './store/ActionCreator'; import { getCurrentNamespace } from 'services/NamespaceStore'; const PREFIX = 'features.SourceControlManagement'; @@ -49,6 +53,12 @@ const ScmSyncTabs = () => { } }, [pullStateReady]); + useEffect(() => { + if (pushStateReady && pullStateReady) { + setSyncStatusOfAllPipelines(); + } + }, [pushStateReady, pullStateReady]); + const handleTabChange = (e, newValue) => { setTabIndex(newValue); // refetch latest pipeline data, while displaying possibly stale data diff --git a/app/cdap/components/SourceControlManagement/helpers.ts b/app/cdap/components/SourceControlManagement/helpers.ts index a6ac2dbe6b9..e0647bf08f3 100644 --- a/app/cdap/components/SourceControlManagement/helpers.ts +++ b/app/cdap/components/SourceControlManagement/helpers.ts @@ -21,7 +21,12 @@ import { IResource, IOperationResource, IOperationRun, + ITimeInstant, + IOperationError, IOperationResourceScopedErrorMessage, + IRepositoryPipeline, + TSyncStatusFilter, + TSyncStatus, } from './types'; import T from 'i18n-react'; import { ITimeInstant, timeInstantToString } from 'services/DataFormatter'; @@ -154,3 +159,62 @@ export const getOperationRunTime = (operation: IOperationRun): string => { } return null; }; + +export const getOperationRunTime = (operation: IOperationRun): string => { + if (operation.metadata?.createTime && operation.metadata?.endTime) { + return moment + .duration( + (operation.metadata?.endTime.seconds - operation.metadata?.createTime.seconds) * 1000 + ) + .humanize(); + } + return null; +}; + +const getSyncStatusWeight = (syncStatus?: TSyncStatus): number => { + if (syncStatus === undefined) { + return 0; + } + if (syncStatus === 'not_available') { + return 0; + } + if (syncStatus === 'not_connected') { + return 0; + } + if (syncStatus === 'out_of_sync') { + return 1; + } + return 2; +}; + +export const compareSyncStatus = (a: IRepositoryPipeline, b: IRepositoryPipeline): number => { + return getSyncStatusWeight(a.syncStatus) - getSyncStatusWeight(b.syncStatus); +}; + +export const stableSort = (array, comparator) => { + const stabilizedArray = array.map((el, index) => [el, index]); + stabilizedArray.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + }); + return stabilizedArray.map((el) => el[0]); +}; + +export const filterOnSyncStatus = ( + array: IRepositoryPipeline[], + syncStatusFilter: TSyncStatusFilter +): IRepositoryPipeline[] => { + if (syncStatusFilter === 'all') { + return array; + } + + return array.filter((pipeline) => { + if (syncStatusFilter === 'out_of_sync') { + return ['not_connected', 'out_of_sync', 'not_available'].includes(pipeline.syncStatus); + } + return pipeline.syncStatus === syncStatusFilter; + }); +}; diff --git a/app/cdap/components/SourceControlManagement/store/ActionCreator.ts b/app/cdap/components/SourceControlManagement/store/ActionCreator.ts index e4250eac356..5b22c042578 100644 --- a/app/cdap/components/SourceControlManagement/store/ActionCreator.ts +++ b/app/cdap/components/SourceControlManagement/store/ActionCreator.ts @@ -27,7 +27,14 @@ import SourceControlManagementSyncStore, { } from '.'; import { SourceControlApi } from 'api/sourcecontrol'; import { LongRunningOperationApi } from 'api/longRunningOperation'; -import { IPipeline, IPushResponse, IRepositoryPipeline, IOperationRun } from '../types'; +import { + IPipeline, + IPushResponse, + IRepositoryPipeline, + IOperationRun, + TSyncStatusFilter, + TSyncStatus, +} from '../types'; import { SUPPORT } from 'components/StatusButton/constants'; import { compareTimeInstant } from '../helpers'; import { getCurrentNamespace } from 'services/NamespaceStore'; @@ -36,6 +43,7 @@ const PREFIX = 'features.SourceControlManagement'; // push actions export const getNamespacePipelineList = (namespace, nameFilter = null) => { + const shouldCalculateSyncStatus = SourceControlManagementSyncStore.getState().pull.ready; MyPipelineApi.list({ namespace, artifactName: BATCH_PIPELINE_TYPE, @@ -44,13 +52,18 @@ export const getNamespacePipelineList = (namespace, nameFilter = null) => { (res: IPipeline[] | { applications: IPipeline[] }) => { const pipelines = Array.isArray(res) ? res : res?.applications; const nsPipelines = pipelines.map((pipeline) => { - return { + const localPipeline: IRepositoryPipeline = { name: pipeline.name, fileHash: pipeline.sourceControlMeta?.fileHash, lastSyncDate: pipeline.sourceControlMeta?.lastSyncedAt, error: null, status: null, + syncStatus: 'not_available', }; + if (shouldCalculateSyncStatus) { + localPipeline.syncStatus = getSyncStatus(localPipeline); + } + return localPipeline; }); setLocalPipelines(nsPipelines); }, @@ -60,6 +73,55 @@ export const getNamespacePipelineList = (namespace, nameFilter = null) => { ); }; +const getSyncStatus = (pipeline: IRepositoryPipeline, withLocal?: boolean): TSyncStatus => { + const listOfPipelines = withLocal + ? SourceControlManagementSyncStore.getState().push.localPipelines + : SourceControlManagementSyncStore.getState().pull.remotePipelines; + + const pipelineInList = listOfPipelines.find((p) => p.name === pipeline.name); + if (!pipeline.fileHash || !pipelineInList) { + return 'not_connected'; + } + return pipeline.fileHash === pipelineInList.fileHash ? 'in_sync' : 'out_of_sync'; +}; + +export const setSyncStatusOfLocalPipelines = () => { + const localPipelines = SourceControlManagementSyncStore.getState().push.localPipelines; + const remotePipelines = SourceControlManagementSyncStore.getState().pull.remotePipelines; + if (!remotePipelines.length) { + return; + } + + setLocalPipelines( + localPipelines.map((pipeline) => { + const p = { ...pipeline }; + p.syncStatus = getSyncStatus(pipeline); + return p; + }) + ); +}; + +export const setSyncStatusOfRemotePipelines = () => { + const localPipelines = SourceControlManagementSyncStore.getState().push.localPipelines; + const remotePipelines = SourceControlManagementSyncStore.getState().pull.remotePipelines; + if (!localPipelines.length) { + return; + } + + setRemotePipelines( + remotePipelines.map((pipeline) => { + const p = { ...pipeline }; + p.syncStatus = getSyncStatus(pipeline, true); + return p; + }) + ); +}; + +export const setSyncStatusOfAllPipelines = () => { + setSyncStatusOfLocalPipelines(); + setSyncStatusOfRemotePipelines(); +}; + export const setLocalPipelines = (pipelines: IRepositoryPipeline[]) => { SourceControlManagementSyncStore.dispatch({ type: PushToGitActions.setLocalPipelines, @@ -86,6 +148,15 @@ export const setNameFilter = (nameFilter: string) => { debouncedApplySearch(); }; +export const setSyncStatusFilter = (syncStatusFilter: TSyncStatusFilter) => { + SourceControlManagementSyncStore.dispatch({ + type: PushToGitActions.setSyncStatusFilter, + payload: { + syncStatusFilter, + }, + }); +}; + export const setSelectedPipelines = (selectedPipelines: any[]) => { SourceControlManagementSyncStore.dispatch({ type: PushToGitActions.setSelectedPipelines, @@ -199,18 +270,24 @@ export const reset = () => { // pull actions export const getRemotePipelineList = (namespace) => { + const shouldCalculateSyncStatus = SourceControlManagementSyncStore.getState().push.ready; SourceControlApi.list({ namespace, }).subscribe( (res: IRepositoryPipeline[] | { apps: IRepositoryPipeline[] }) => { const pipelines = Array.isArray(res) ? res : res?.apps; const remotePipelines = pipelines.map((pipeline) => { - return { + const remotePipeline: IRepositoryPipeline = { name: pipeline.name, fileHash: pipeline.fileHash, error: null, status: null, + syncStatus: 'not_available', }; + if (shouldCalculateSyncStatus) { + remotePipeline.syncStatus = getSyncStatus(remotePipeline, true); + } + return remotePipeline; }); setRemotePipelines(remotePipelines); }, @@ -248,6 +325,15 @@ export const setRemoteNameFilter = (nameFilter: string) => { }); }; +export const setRemoteSyncStatusFilter = (syncStatusFilter: TSyncStatusFilter) => { + SourceControlManagementSyncStore.dispatch({ + type: PullFromGitActions.setSyncStatusFilter, + payload: { + syncStatusFilter, + }, + }); +}; + export const setSelectedRemotePipelines = (selectedPipelines: string[]) => { SourceControlManagementSyncStore.dispatch({ type: PullFromGitActions.setSelectedPipelines, diff --git a/app/cdap/components/SourceControlManagement/store/index.ts b/app/cdap/components/SourceControlManagement/store/index.ts index 9591d223ac5..34ba8558e68 100644 --- a/app/cdap/components/SourceControlManagement/store/index.ts +++ b/app/cdap/components/SourceControlManagement/store/index.ts @@ -17,13 +17,13 @@ import { combineReducers, createStore, Store as StoreInterface } from 'redux'; import { composeEnhancers } from 'services/helpers'; import { IAction } from 'services/redux-helpers'; -import { IOperationRun, IRepositoryPipeline } from '../types'; -import { act } from 'react-dom/test-utils'; +import { IOperationRun, IRepositoryPipeline, TSyncStatusFilter } from '../types'; interface IPushViewState { ready: boolean; localPipelines: IRepositoryPipeline[]; nameFilter: string; + syncStatusFilter: TSyncStatusFilter; selectedPipelines: string[]; commitModalOpen: boolean; loadingMessage: string; @@ -34,6 +34,7 @@ interface IPullViewState { ready: boolean; remotePipelines: IRepositoryPipeline[]; nameFilter: string; + syncStatusFilter: TSyncStatusFilter; selectedPipelines: string[]; loadingMessage: string; showFailedOnly: boolean; @@ -55,6 +56,7 @@ export const PushToGitActions = { setLocalPipelines: 'LOCAL_PIPELINES_SET', reset: 'LOCAL_PIPELINES_RESET', setNameFilter: 'LOCAL_PIPELINES_SET_NAME_FILTER', + setSyncStatusFilter: 'LOCAL_PIPELINES_SET_SYNC_STATUS_FILTER', applySearch: 'LOCAL_PIPELINES_APPLY_SERACH', setSelectedPipelines: 'LOCAL_PIPELINES_SET_SELECTED_PIPELINES', toggleCommitModal: 'LOCAL_PIPELINES_TOGGLE_COMMIT_MODAL', @@ -66,6 +68,7 @@ export const PullFromGitActions = { setRemotePipelines: 'REMOTE_PIPELINES_SET', reset: 'REMOTE_PIPELINES_RESET', setNameFilter: 'REMOTE_PIPELINES_SET_NAME_FILTER', + setSyncStatusFilter: 'REMOTE_PIPELINES_SET_SYNC_STATUS_FILTER', applySearch: 'REMOTE_PIPELINES_APPLY_SERACH', setSelectedPipelines: 'REMOTE_PIPELINES_SET_SELECTED_PIPELINES', setLoadingMessage: 'REMOTE_PIPELINES_SET_LOADING_MESSAGE', @@ -82,6 +85,7 @@ const defaultPushViewState: IPushViewState = { ready: false, localPipelines: [], nameFilter: '', + syncStatusFilter: 'all', selectedPipelines: [], commitModalOpen: false, loadingMessage: null, @@ -92,6 +96,7 @@ const defaultPullViewState: IPullViewState = { ready: false, remotePipelines: [], nameFilter: '', + syncStatusFilter: 'all', selectedPipelines: [], loadingMessage: null, showFailedOnly: false, @@ -115,6 +120,11 @@ const push = (state = defaultPushViewState, action: IAction) => { ...state, nameFilter: action.payload.nameFilter, }; + case PushToGitActions.setSyncStatusFilter: + return { + ...state, + syncStatusFilter: action.payload.syncStatusFilter, + }; case PushToGitActions.applySearch: return { ...defaultPushViewState, @@ -161,6 +171,11 @@ const pull = (state = defaultPullViewState, action: IAction) => { ...state, nameFilter: action.payload.nameFilter, }; + case PullFromGitActions.setSyncStatusFilter: + return { + ...state, + syncStatusFilter: action.payload.syncStatusFilter, + }; case PullFromGitActions.setPullViewErrorMsg: return { ...state, diff --git a/app/cdap/components/SourceControlManagement/styles.ts b/app/cdap/components/SourceControlManagement/styles.ts index a20e5289c79..e84ee62d622 100644 --- a/app/cdap/components/SourceControlManagement/styles.ts +++ b/app/cdap/components/SourceControlManagement/styles.ts @@ -82,3 +82,16 @@ export const FailStatusDiv = styled.div` export const AlertErrorView = styled.p` margin: 1rem 0; `; + +export const FiltersAndStatusWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +`; + +export const SyncStatusWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; diff --git a/app/cdap/components/SourceControlManagement/types.ts b/app/cdap/components/SourceControlManagement/types.ts index c1b17e4324b..7dff7e68e68 100644 --- a/app/cdap/components/SourceControlManagement/types.ts +++ b/app/cdap/components/SourceControlManagement/types.ts @@ -20,12 +20,16 @@ import { OperationType } from './OperationType'; import { OperationStatus } from './OperationStatus'; import { ITimeInstant } from 'services/DataFormatter'; +export type TSyncStatusFilter = 'all' | 'in_sync' | 'out_of_sync'; +export type TSyncStatus = 'not_available' | 'in_sync' | 'out_of_sync' | 'not_connected'; + export interface IRepositoryPipeline { name: string; fileHash: string; error: string; status: SUPPORT; lastSyncDate?: ITimeInstant; + syncStatus?: TSyncStatus; } export interface IOperationResource { diff --git a/app/cdap/text/text-en.yaml b/app/cdap/text/text-en.yaml index 09b5e1680bc..4198c21628a 100644 --- a/app/cdap/text/text-en.yaml +++ b/app/cdap/text/text-en.yaml @@ -3281,10 +3281,17 @@ features: tab: Namespace pipelines stopOperation: STOP table: + filtersLabel: Filters connected: Connected pipelineName: Pipeline name lastSyncDate: Last sync date gitStatus: Connected to Git + gitSyncStatus: Git Status + gitSyncStatusAll: All + gitSyncStatusSynced: Synced + gitSyncStatusUnsynced: Unsynced + gitSyncStatusFetching: fetching ... + gitSyncStatusNotConnected: Not connected gitStatusHelperText: This status indicates that the pipeline has been pushed to or pulled from the git repository in the past. It does not necessarily mean the content is up to date. pullFail: Failed to pull this pipeline from remote. pushFail: Failed to push this pipeline to remote.