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 0284d185ca3..7a7907e9bb9 100644 --- a/app/cdap/components/SourceControlManagement/LocalPipelineListView/PipelineTable.tsx +++ b/app/cdap/components/SourceControlManagement/LocalPipelineListView/PipelineTable.tsx @@ -14,11 +14,19 @@ * 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, +} from '@material-ui/core'; import InfoIcon from '@material-ui/icons/Info'; 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'; @@ -30,6 +38,8 @@ import { StyledFixedWidthCell, StyledPopover, } from '../styles'; +import { compareSyncStatus, filterOnSyncStatus, stableSort } from '../helpers'; +import LoadingSVG from 'components/shared/LoadingSVG'; const PREFIX = 'features.SourceControlManagement.table'; @@ -37,18 +47,21 @@ 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 isSelected = (name: string) => selectedPipelines.indexOf(name) !== -1; + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const handleSelectAllClick = (event: React.ChangeEvent) => { if (disabled) { @@ -56,7 +69,9 @@ export const LocalPipelineTable = ({ } if (event.target.checked) { - const allSelected = localPipelines.map((pipeline) => pipeline.name); + const allSelected = filterOnSyncStatus(localPipelines, syncStatusFilter).map( + (pipeline) => pipeline.name + ); setSelectedPipelines(allSelected); return; } @@ -68,7 +83,7 @@ export const LocalPipelineTable = ({ return; } - if (enableMultipleSelection) { + if (multiPushEnabled) { handleMultipleSelection(name); return; } @@ -97,13 +112,22 @@ export const LocalPipelineTable = ({ setSelectedPipelines(newSelected); }; + const handleSort = () => { + const isAsc = sortOrder === 'asc'; + setSortOrder(isAsc ? 'desc' : 'asc'); + }; + + const syncStatusComparator = (a: IRepositoryPipeline, b: IRepositoryPipeline) => { + return sortOrder === 'desc' ? compareSyncStatus(a, b) : -compareSyncStatus(a, b); + }; + return ( - {enableMultipleSelection && ( + {multiPushEnabled && ( {T.translate(`${PREFIX}.pipelineName`)} - -
- {T.translate(`${PREFIX}.gitStatus`)} - } showOn="Hover"> - {T.translate(`${PREFIX}.gitStatusHelperText`)} - -
-
+ {multiPushEnabled && ( + + + {T.translate(`${PREFIX}.gitSyncStatus`)} + + + )} + {!multiPushEnabled && ( + +
+ {T.translate(`${PREFIX}.gitStatus`)} + } showOn="Hover"> + {T.translate(`${PREFIX}.gitStatusHelperText`)} + +
+
+ )}
- {localPipelines.map((pipeline: IRepositoryPipeline) => { + {stableSort( + filterOnSyncStatus(localPipelines, syncStatusFilter), + syncStatusComparator + ).map((pipeline: IRepositoryPipeline) => { if (showFailedOnly && !pipeline.error) { // only render pipelines that failed to push return; @@ -167,9 +203,24 @@ export const LocalPipelineTable = ({ )} {pipeline.name} - - {pipeline.fileHash ? T.translate(`${PREFIX}.connected`) : '--'} - + {multiPushEnabled && ( + + {pipeline.syncStatus === undefined || + pipeline.syncStatus === 'not_available' ? ( + + ) : 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 5b57b300607..304e62d3d59 100644 --- a/app/cdap/components/SourceControlManagement/LocalPipelineListView/index.tsx +++ b/app/cdap/components/SourceControlManagement/LocalPipelineListView/index.tsx @@ -15,23 +15,20 @@ */ 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 Alert from '@material-ui/lab/Alert'; -import AlertTitle from '@material-ui/lab/AlertTitle'; import { countPushFailedPipelines, fetchLatestOperation, getNamespacePipelineList, pushMultipleSelectedPipelines, pushSelectedPipelines, - reset, resetPushStatus, setLoadingMessage, setLocalPipelines, setNameFilter, - stopOperation, + setSyncStatusFilter, toggleCommitModal, toggleShowFailedOnly, } from '../store/ActionCreator'; @@ -42,30 +39,16 @@ import { CommitModal } from './CommitModal'; import cloneDeep from 'lodash/cloneDeep'; import PrimaryTextButton from 'components/shared/Buttons/PrimaryTextButton'; import { LocalPipelineTable } from './PipelineTable'; -import { useOnUnmount } from 'services/react/customHooks/useOnUnmount'; -import { - AlertErrorView, - FailStatusDiv, - PipelineListContainer, - StyledSelectionStatusDiv, -} from '../styles'; -import { IListResponse, IOperationMetaResponse, IOperationRun } from '../types'; +import { FailStatusDiv, PipelineListContainer, StyledSelectionStatusDiv } from '../styles'; +import { IListResponse, IOperationMetaResponse } from '../types'; import { useFeatureFlagDefaultFalse } from 'services/react/customHooks/useFeatureFlag'; -import { - getOperationRunMessage, - getOperationStartTime, - getOperationStatusType, - parseOperationResource, -} from '../helpers'; -import Button from '@material-ui/core/Button'; -import { OperationStatus } from '../OperationStatus'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; +import { parseOperationResource } from '../helpers'; +import { OperationAlert } from '../OperationAlert'; +import { SyncStatusFilters } from '../SyncStatusFilters'; const PREFIX = 'features.SourceControlManagement.push'; export const LocalPipelineListView = () => { - const [viewErrorExpanded, setViewErrorExpanded] = useState(false); const { ready, localPipelines, @@ -74,6 +57,7 @@ export const LocalPipelineListView = () => { commitModalOpen, loadingMessage, showFailedOnly, + syncStatusFilter, } = useSelector(({ push }) => push); const { running: isAnOperationRunning, operation } = useSelector( @@ -97,8 +81,6 @@ export const LocalPipelineListView = () => { } }, []); - useOnUnmount(() => reset()); - const onPushSubmit = (commitMessage: string) => { resetPushStatus(); const pushedPipelines = cloneDeep(localPipelines); @@ -156,12 +138,19 @@ export const LocalPipelineListView = () => { if (localPipelines.length > 0) { return ( <> + {multiPushEnabled && ( + + )} { return
{T.translate(`${PREFIX}.emptyPipelineListMessage`, { query: nameFilter })}
; }; - const getOperationAction = () => { - if (!operation.done) { - return ( - - ); - } - - if (operation.status === OperationStatus.FAILED) { - return ( - - ); - } - - return undefined; - }; - return ( + {operation && multiPushEnabled && } - {operation && ( - - {getOperationRunMessage(operation)} - {getOperationStartTime(operation)} - {operation.status === OperationStatus.FAILED && viewErrorExpanded && ( - - Operation ID: {operation.id} -
- Error: {operation.error.message} -
- )} -
- )} {selectedPipelines.length > 0 && (
diff --git a/app/cdap/components/SourceControlManagement/OperationAlert.tsx b/app/cdap/components/SourceControlManagement/OperationAlert.tsx new file mode 100644 index 00000000000..174e8291dec --- /dev/null +++ b/app/cdap/components/SourceControlManagement/OperationAlert.tsx @@ -0,0 +1,147 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import T from 'i18n-react'; +import { IOperationRun } from './types'; +import Alert from '@material-ui/lab/Alert'; +import AlertTitle from '@material-ui/lab/AlertTitle'; +import { + getOperationRunMessage, + getOperationRunTime, + getOperationStartTime, + getOperationStatusType, + parseOperationResource, +} from './helpers'; +import { Button, CircularProgress } from '@material-ui/core'; +import { stopOperation } from './store/ActionCreator'; +import { getCurrentNamespace } from 'services/NamespaceStore'; +import { OperationStatus } from './OperationStatus'; +import ExpandLess from '@material-ui/icons/ExpandLess'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import { AlertErrorView } from './styles'; + +interface IOperationBannerProps { + operation: IOperationRun; +} + +const StyledDiv = styled.div` + margin-bottom: 24px; +`; + +const ExpandWrapper = styled.div` + height: 100%; + padding-top: 12px; +`; + +const PREFIX = 'features.SourceControlManagement'; + +export const OperationAlert = ({ operation }: IOperationBannerProps) => { + const [viewErrorExpanded, setViewErrorExpanded] = useState(false); + + const getOperationAction = () => { + if ( + operation.status === OperationStatus.STARTING || + operation.status === OperationStatus.RUNNING + ) { + return ( + + ); + } + + if (operation.status === OperationStatus.STOPPING) { + return ( + + ); + } + + if (operation.status === OperationStatus.FAILED) { + return ( + + + + ); + } + + return undefined; + }; + + const renderOperationTime = () => { + const startedPrefix = T.translate(`${PREFIX}.operationStartedAt`).toString(); + const startTime = getOperationStartTime(operation); + const timeTaken = getOperationRunTime(operation); + if (!timeTaken) { + return `${startedPrefix} ${startTime}`; + } + + const timeTakenPrefix = operation.done + ? T.translate(`${PREFIX}.operationRanFor`).toString() + : T.translate(`${PREFIX}.operationRunningFor`).toString(); + + return `${startedPrefix} ${startTime}, ${timeTakenPrefix} ${timeTaken}`; + }; + + function renderErrorMessage(message?: string) { + if (!message) { + return operation.error?.message; + } + + const firstColonIndex = message.indexOf(':'); + return message.substring(firstColonIndex + 1); + } + + return ( + + + {getOperationRunMessage(operation)} + {renderOperationTime()} + {operation.status === OperationStatus.FAILED && viewErrorExpanded && ( + + Operation ID: {operation.id} + {operation.error?.details[0] && ( + <> +
+ Pipeline Name: {parseOperationResource(operation.error?.details[0]).name} +
+ Error: {renderErrorMessage(operation.error?.details[0]?.message)} + + )} +
+ )} +
+
+ ); +}; diff --git a/app/cdap/components/SourceControlManagement/RemotePipelineListView/RemotePipelineTable.tsx b/app/cdap/components/SourceControlManagement/RemotePipelineListView/RemotePipelineTable.tsx index dca38c86905..34ad2fd3e8e 100644 --- a/app/cdap/components/SourceControlManagement/RemotePipelineListView/RemotePipelineTable.tsx +++ b/app/cdap/components/SourceControlManagement/RemotePipelineListView/RemotePipelineTable.tsx @@ -14,14 +14,24 @@ * the License. */ -import React from 'react'; +import React, { useState } from 'react'; import T from 'i18n-react'; -import { Checkbox, Table, TableBody, TableCell, TableRow, TableHead } from '@material-ui/core'; +import { + Checkbox, + Table, + TableBody, + TableCell, + TableRow, + TableHead, + TableSortLabel, +} from '@material-ui/core'; import { setSelectedRemotePipelines } from '../store/ActionCreator'; -import { IRepositoryPipeline } from '../types'; +import { IRepositoryPipeline, TSyncStatusFilter } from '../types'; import StatusButton from 'components/StatusButton'; import { SUPPORT } from 'components/StatusButton/constants'; -import { StyledTableCell, StyledTableRow, TableBox } from '../styles'; +import { StyledFixedWidthCell, StyledTableCell, StyledTableRow, TableBox } from '../styles'; +import { compareSyncStatus, filterOnSyncStatus, stableSort } from '../helpers'; +import LoadingSVG from 'components/shared/LoadingSVG'; const PREFIX = 'features.SourceControlManagement.table'; @@ -29,25 +39,28 @@ interface IRepositoryPipelineTableProps { remotePipelines: IRepositoryPipeline[]; selectedPipelines: string[]; showFailedOnly: boolean; - enableMultipleSelection?: boolean; + multiPullEnabled?: boolean; disabled?: boolean; + syncStatusFilter?: TSyncStatusFilter; } export const RemotePipelineTable = ({ remotePipelines, selectedPipelines, showFailedOnly, - enableMultipleSelection = false, + multiPullEnabled = false, disabled = false, + syncStatusFilter = 'all', }: IRepositoryPipelineTableProps) => { const isSelected = (name: string) => selectedPipelines.indexOf(name) !== -1; + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const handleClick = (event: React.MouseEvent, name: string) => { if (disabled) { return; } - if (enableMultipleSelection) { + if (multiPullEnabled) { handleMultipleSelection(name); return; } @@ -83,20 +96,31 @@ export const RemotePipelineTable = ({ } if (event.target.checked) { - const allSelected = remotePipelines.map((pipeline) => pipeline.name); + const allSelected = filterOnSyncStatus(remotePipelines, syncStatusFilter).map( + (pipeline) => pipeline.name + ); setSelectedRemotePipelines(allSelected); return; } setSelectedRemotePipelines([]); }; + const handleSort = () => { + const isAsc = sortOrder === 'asc'; + setSortOrder(isAsc ? 'desc' : 'asc'); + }; + + const syncStatusComparator = (a: IRepositoryPipeline, b: IRepositoryPipeline) => { + return sortOrder === 'desc' ? compareSyncStatus(a, b) : -compareSyncStatus(a, b); + }; + return (
- {enableMultipleSelection && ( + {multiPullEnabled && ( {T.translate(`${PREFIX}.pipelineName`)} + {multiPullEnabled && ( + + + {T.translate(`${PREFIX}.gitSyncStatus`)} + + + )} - {remotePipelines.map((pipeline: IRepositoryPipeline) => { + {stableSort( + filterOnSyncStatus(remotePipelines, syncStatusFilter), + syncStatusComparator + ).map((pipeline: IRepositoryPipeline) => { if (showFailedOnly && !pipeline.error) { // only render pipelines that failed to pull return; @@ -154,6 +188,19 @@ export const RemotePipelineTable = ({ )} {pipeline.name} + {multiPullEnabled && ( + + {pipeline.syncStatus === undefined || + pipeline.syncStatus === 'not_available' ? ( + + ) : 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 c2b9684e686..eee2e986aaf 100644 --- a/app/cdap/components/SourceControlManagement/RemotePipelineListView/index.tsx +++ b/app/cdap/components/SourceControlManagement/RemotePipelineListView/index.tsx @@ -14,19 +14,12 @@ * 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 { default as MuiAlert } from '@material-ui/lab/Alert'; -import AlertTitle from '@material-ui/lab/AlertTitle'; import { SearchBox } from '../SearchBox'; -import { - AlertErrorView, - FailStatusDiv, - PipelineListContainer, - StyledSelectionStatusDiv, -} from '../styles'; +import { FailStatusDiv, PipelineListContainer, StyledSelectionStatusDiv } from '../styles'; import { RemotePipelineTable } from './RemotePipelineTable'; import { countPullFailedPipelines, @@ -34,45 +27,39 @@ import { pullAndDeploySelectedRemotePipelines, resetPullStatus, setPullViewErrorMsg, - resetRemote, setRemoteLoadingMessage, setRemoteNameFilter, setRemotePipelines, toggleRemoteShowFailedOnly, pullAndDeployMultipleSelectedRemotePipelines, fetchLatestOperation, - stopOperation, + setRemoteSyncStatusFilter, } 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'; import Alert from 'components/shared/Alert'; import { useFeatureFlagDefaultFalse } from 'services/react/customHooks/useFeatureFlag'; -import { - getOperationRunMessage, - getOperationStartTime, - getOperationStatusType, - parseOperationResource, -} from '../helpers'; -import Button from '@material-ui/core/Button'; -import { OperationStatus } from '../OperationStatus'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; +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) => { - const [viewErrorExpanded, setViewErrorExpanded] = useState(false); +export const RemotePipelineListView = ({ + redirectOnSubmit, + singlePipelineMode, +}: IRemotePipelineListViewProps) => { const { ready, remotePipelines, @@ -81,15 +68,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(() => { @@ -104,8 +92,6 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList } }, []); - useOnUnmount(() => resetRemote()); - const filteredPipelines = remotePipelines.filter((pipeline) => pipeline.name.toLowerCase().includes(nameFilter.toLowerCase()) ); @@ -169,12 +155,19 @@ export const RemotePipelineListView = ({ redirectOnSubmit }: IRemotePipelineList if (filteredPipelines.length > 0) { return ( <> + {multiPullEnabled && ( + + )} {T.translate(`${PREFIX}.emptyPipelineListMessage`, { query: nameFilter })}; }; - const getOperationAction = () => { - if (!operation.done) { - return ( - - ); - } - - if (operation.status === OperationStatus.FAILED) { - return ( - - ); - } - - return undefined; - }; - return ( <> setPullViewErrorMsg()} /> + {operation && multiPullEnabled && } - {operation && ( - - {getOperationRunMessage(operation)} - {getOperationStartTime(operation)} - {operation.status === OperationStatus.FAILED && viewErrorExpanded && ( - - Operation ID: {operation.id} -
- Error: {operation.error.message} -
- )} -
- )} {selectedPipelines.length > 0 && (
diff --git a/app/cdap/components/SourceControlManagement/SyncStatusFilters.tsx b/app/cdap/components/SourceControlManagement/SyncStatusFilters.tsx new file mode 100644 index 00000000000..fe897d7a24a --- /dev/null +++ b/app/cdap/components/SourceControlManagement/SyncStatusFilters.tsx @@ -0,0 +1,75 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import T from 'i18n-react'; +import { TSyncStatusFilter } from './types'; +import { Button, ButtonGroup } from '@material-ui/core'; + +const PREFIX = 'features.SourceControlManagement.table'; + +const StyledDiv = styled.div` + margin: 12px 0px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; +`; + +interface ISyncStatusFiltersProps { + syncStatusFilter: TSyncStatusFilter; + setSyncStatusFilter: (syncStatusFilter: TSyncStatusFilter) => 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 new file mode 100644 index 00000000000..82c3afd0fe9 --- /dev/null +++ b/app/cdap/components/SourceControlManagement/SyncTabs.tsx @@ -0,0 +1,96 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +import { Tab, Tabs } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { LocalPipelineListView } from './LocalPipelineListView'; +import styled from 'styled-components'; +import T from 'i18n-react'; +import { RemotePipelineListView } from './RemotePipelineListView'; +import { FeatureProvider } from 'services/react/providers/featureFlagProvider'; +import { + getNamespacePipelineList, + getRemotePipelineList, + setSyncStatusOfLocalPipelines, + setSyncStatusOfRemotePipelines, +} from './store/ActionCreator'; +import { getCurrentNamespace } from 'services/NamespaceStore'; + +const PREFIX = 'features.SourceControlManagement'; + +const StyledDiv = styled.div` + padding: 10px; + margin-top: 10px; +`; + +const ScmSyncTabs = () => { + const [tabIndex, setTabIndex] = useState(0); + + const { ready: pushStateReady, nameFilter } = useSelector(({ push }) => push); + useEffect(() => { + if (!pushStateReady) { + getNamespacePipelineList(getCurrentNamespace(), nameFilter); + } + }, [pushStateReady]); + + const { ready: pullStateReady } = useSelector(({ pull }) => pull); + useEffect(() => { + if (!pullStateReady) { + getRemotePipelineList(getCurrentNamespace()); + } + }, [pullStateReady]); + + useEffect(() => { + if (pushStateReady && pullStateReady) { + setSyncStatusOfLocalPipelines(); + setSyncStatusOfRemotePipelines(); + } + }, [pushStateReady, pullStateReady]); + + const handleTabChange = (e, newValue) => { + setTabIndex(newValue); + // refetch latest pipeline data, while displaying possibly stale data + if (newValue === 0) { + getNamespacePipelineList(getCurrentNamespace(), nameFilter); + } else { + getRemotePipelineList(getCurrentNamespace()); + } + }; + + return ( + <> + + + + + + + + + {tabIndex === 0 ? : } + + + + ); +}; + +export default ScmSyncTabs; diff --git a/app/cdap/components/SourceControlManagement/helpers.ts b/app/cdap/components/SourceControlManagement/helpers.ts index c8657bb58c2..1e1e1003bbf 100644 --- a/app/cdap/components/SourceControlManagement/helpers.ts +++ b/app/cdap/components/SourceControlManagement/helpers.ts @@ -17,7 +17,17 @@ import moment from 'moment'; import { OperationStatus } from './OperationStatus'; import { OperationType } from './OperationType'; -import { IResource, IOperationResource, IOperationRun, ITimeInstant } from './types'; +import { + IResource, + IOperationResource, + IOperationRun, + ITimeInstant, + IOperationError, + IOperationResourceScopedErrorMessage, + IRepositoryPipeline, + TSyncStatusFilter, + TSyncStatus, +} from './types'; import T from 'i18n-react'; const PREFIX = 'features.SourceControlManagement'; @@ -34,6 +44,20 @@ export const parseOperationResource = (resource: IOperationResource): IResource }; }; +export const parseErrorMessage = (errorMessage: string): IOperationResourceScopedErrorMessage => { + const firstColonIndex = errorMessage.indexOf(':'); + if (firstColonIndex === -1) { + return { + message: errorMessage, + }; + } + + return { + type: errorMessage.substring(0, firstColonIndex).trim(), + message: errorMessage.substring(firstColonIndex + 1).trim(), + }; +}; + export const getOperationRunMessage = (operation: IOperationRun) => { const n = operation.metadata?.resources?.length || ''; @@ -123,3 +147,62 @@ export const compareTimeInstant = (t1: ITimeInstant, t2: ITimeInstant): number = export const getOperationStartTime = (operation: IOperationRun): string => { return moment(operation.metadata?.createTime.seconds * 1000).format('DD-MM-YYYY HH:mm:ss A'); }; + +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/index.tsx b/app/cdap/components/SourceControlManagement/index.tsx index af6dad7d893..0b7191f1b78 100644 --- a/app/cdap/components/SourceControlManagement/index.tsx +++ b/app/cdap/components/SourceControlManagement/index.tsx @@ -14,31 +14,23 @@ * the License. */ -import { Tab, Tabs } from '@material-ui/core'; import { EntityTopPanel } from 'components/EntityTopPanel'; -import React, { useState } from 'react'; -import { LocalPipelineListView } from './LocalPipelineListView'; +import React from 'react'; import { Provider } from 'react-redux'; import SourceControlManagementSyncStore from './store'; -import styled from 'styled-components'; import T from 'i18n-react'; -import { RemotePipelineListView } from './RemotePipelineListView'; import { getCurrentNamespace } from 'services/NamespaceStore'; -import { FeatureProvider } from 'services/react/providers/featureFlagProvider'; +import { resetRemote, reset } from './store/ActionCreator'; +import ScmSyncTabs from './SyncTabs'; +import { useOnUnmount } from 'services/react/customHooks/useOnUnmount'; const PREFIX = 'features.SourceControlManagement'; -const StyledDiv = styled.div` - padding: 10px; - margin-top: 10px; -`; - const SourceControlManagementSyncView = () => { - const [tabIndex, setTabIndex] = useState(0); - - const handleTabChange = (e, newValue) => { - setTabIndex(newValue); - }; + useOnUnmount(() => { + resetRemote(); + reset(); + }); const closeAndBackLink = `/ns/${getCurrentNamespace()}/details/scm`; @@ -54,22 +46,7 @@ const SourceControlManagementSyncView = () => { window.location.href = closeAndBackLink; }} /> - - - - - - - - - {tabIndex === 0 ? : } - - + ); }; diff --git a/app/cdap/components/SourceControlManagement/store/ActionCreator.ts b/app/cdap/components/SourceControlManagement/store/ActionCreator.ts index 999ae6828ee..c65d00a3493 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'; @@ -35,6 +42,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, @@ -43,12 +51,17 @@ 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, error: null, status: null, + syncStatus: 'not_available', }; + if (shouldCalculateSyncStatus) { + localPipeline.syncStatus = getSyncStatus(localPipeline); + } + return localPipeline; }); setLocalPipelines(nsPipelines); }, @@ -58,6 +71,50 @@ 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 setLocalPipelines = (pipelines: IRepositoryPipeline[]) => { SourceControlManagementSyncStore.dispatch({ type: PushToGitActions.setLocalPipelines, @@ -84,6 +141,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, @@ -197,18 +263,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); }, @@ -246,6 +318,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/types.ts b/app/cdap/components/SourceControlManagement/types.ts index 952e80bdcbe..9201ffebecd 100644 --- a/app/cdap/components/SourceControlManagement/types.ts +++ b/app/cdap/components/SourceControlManagement/types.ts @@ -19,11 +19,15 @@ import { SUPPORT } from 'components/StatusButton/constants'; import { OperationType } from './OperationType'; import { OperationStatus } from './OperationStatus'; +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; + syncStatus?: TSyncStatus; } export interface IOperationResource { @@ -51,6 +55,11 @@ export interface IOperationResourceScopedError { message?: string; } +export interface IOperationResourceScopedErrorMessage { + type?: string; + message: string; +} + export interface IOperationRun { id: string; type: OperationType; diff --git a/app/cdap/text/text-en.yaml b/app/cdap/text/text-en.yaml index d2db1f1b5a9..e8a6b2fd963 100644 --- a/app/cdap/text/text-en.yaml +++ b/app/cdap/text/text-en.yaml @@ -3236,6 +3236,10 @@ features: pipelineSyncMessage: Syncing 1 pipeline with the remote repository pipelineSyncedSuccess: Successfully synced 1 pipeline with the remote repository pipelineSyncedFail: Failed to sync 1 pipeline with the remote repository + stopOperation: STOP + operationStartedAt: Started at + operationRanFor: Completed in + operationRunningFor: Running for pull: emptyPipelineListMessage: There are no pipelines in the remote repository or no pipelines matching the search query "{query}" modalTitle: Pull pipeline from remote repository @@ -3276,9 +3280,15 @@ features: tab: Local pipelines stopOperation: STOP table: + filtersLabel: Filters connected: Connected pipelineName: Pipeline name gitStatus: Connected to Git + gitSyncStatus: Sync Status + gitSyncStatusAll: All + gitSyncStatusSynced: In sync + gitSyncStatusUnsynced: Out of sync + 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.