diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index ffdbf1a23..64f27833a 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -25,10 +25,10 @@ "Sync Status": "Sync Status", "History": "History", "Events": "Events", + "Application resources": "Application resources", + "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "No resources": "No resources", "There are no resources associated with the application.": "There are no resources associated with the application.", - "Application resources": "Application resources", - "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "Name": "Name", "Namespace": "Namespace", "Sync Wave": "Sync Wave", @@ -108,11 +108,11 @@ "Edit ImageUpdater": "Edit ImageUpdater", "Delete ImageUpdater": "Delete ImageUpdater", "Error: Missing required route parameters": "Error: Missing required route parameters", + "True": "True", + "False": "False", "ImageUpdater details": "ImageUpdater details", "Ready": "Ready", "Whether the last reconciliation completed without errors.": "Whether the last reconciliation completed without errors.", - "True": "True", - "False": "False", "Applications Matched": "Applications Matched", "Number of applications matched by this ImageUpdater.": "Number of applications matched by this ImageUpdater.", "Images Managed": "Images Managed", @@ -311,7 +311,7 @@ "There are no Argo CD Applications in all projects.": "There are no Argo CD Applications in all projects.", "There was an error retrieving applications. Check your connection and reload the page.": "There was an error retrieving applications. Check your connection and reload the page.", "ApplicationSet Applications": "ApplicationSet Applications", - "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.", + "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.", "Revision": "Revision", "No Argo CD ApplicationSets match the label filter": "No Argo CD ApplicationSets match the label filter", "Try removing the filter or selecting a different label to see more ApplicationSets.": "Try removing the filter or selecting a different label to see more ApplicationSets.", diff --git a/locales/en/topology.json b/locales/en/topology.json new file mode 100644 index 000000000..2a05caa36 --- /dev/null +++ b/locales/en/topology.json @@ -0,0 +1,4 @@ +{ + "List view": "List view", + "Graph view": "Graph view" +} diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index 2430d79c3..985a99179 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -25,10 +25,10 @@ "Sync Status": "Sync Status", "History": "History", "Events": "Events", + "Application resources": "Application resources", + "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "No resources": "No resources", "There are no resources associated with the application.": "There are no resources associated with the application.", - "Application resources": "Application resources", - "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "Name": "Name", "Namespace": "Namespace", "Sync Wave": "Sync Wave", @@ -108,11 +108,11 @@ "Edit ImageUpdater": "Edit ImageUpdater", "Delete ImageUpdater": "Delete ImageUpdater", "Error: Missing required route parameters": "Error: Missing required route parameters", + "True": "True", + "False": "False", "ImageUpdater details": "ImageUpdater details", "Ready": "Ready", "Whether the last reconciliation completed without errors.": "Whether the last reconciliation completed without errors.", - "True": "True", - "False": "False", "Applications Matched": "Applications Matched", "Number of applications matched by this ImageUpdater.": "Number of applications matched by this ImageUpdater.", "Images Managed": "Images Managed", @@ -311,7 +311,7 @@ "There are no Argo CD Applications in all projects.": "There are no Argo CD Applications in all projects.", "There was an error retrieving applications. Check your connection and reload the page.": "There was an error retrieving applications. Check your connection and reload the page.", "ApplicationSet Applications": "ApplicationSet Applications", - "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.", + "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.", "Revision": "リビジョン", "No Argo CD ApplicationSets match the label filter": "No Argo CD ApplicationSets match the label filter", "Try removing the filter or selecting a different label to see more ApplicationSets.": "Try removing the filter or selecting a different label to see more ApplicationSets.", diff --git a/locales/ja/topology.json b/locales/ja/topology.json new file mode 100644 index 000000000..2a05caa36 --- /dev/null +++ b/locales/ja/topology.json @@ -0,0 +1,4 @@ +{ + "List view": "List view", + "Graph view": "Graph view" +} diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index 9b2a17804..2ec54761a 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -25,10 +25,10 @@ "Sync Status": "Sync Status", "History": "History", "Events": "Events", + "Application resources": "Application resources", + "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "No resources": "No resources", "There are no resources associated with the application.": "There are no resources associated with the application.", - "Application resources": "Application resources", - "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "Name": "Name", "Namespace": "Namespace", "Sync Wave": "Sync Wave", @@ -108,11 +108,11 @@ "Edit ImageUpdater": "Edit ImageUpdater", "Delete ImageUpdater": "Delete ImageUpdater", "Error: Missing required route parameters": "Error: Missing required route parameters", + "True": "True", + "False": "False", "ImageUpdater details": "ImageUpdater details", "Ready": "Ready", "Whether the last reconciliation completed without errors.": "Whether the last reconciliation completed without errors.", - "True": "True", - "False": "False", "Applications Matched": "Applications Matched", "Number of applications matched by this ImageUpdater.": "Number of applications matched by this ImageUpdater.", "Images Managed": "Images Managed", @@ -311,7 +311,7 @@ "There are no Argo CD Applications in all projects.": "There are no Argo CD Applications in all projects.", "There was an error retrieving applications. Check your connection and reload the page.": "There was an error retrieving applications. Check your connection and reload the page.", "ApplicationSet Applications": "ApplicationSet Applications", - "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.", + "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.", "Revision": "개정 버전", "No Argo CD ApplicationSets match the label filter": "No Argo CD ApplicationSets match the label filter", "Try removing the filter or selecting a different label to see more ApplicationSets.": "Try removing the filter or selecting a different label to see more ApplicationSets.", diff --git a/locales/ko/topology.json b/locales/ko/topology.json new file mode 100644 index 000000000..2a05caa36 --- /dev/null +++ b/locales/ko/topology.json @@ -0,0 +1,4 @@ +{ + "List view": "List view", + "Graph view": "Graph view" +} diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index 873866551..06b545cf7 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -25,10 +25,10 @@ "Sync Status": "Sync Status", "History": "History", "Events": "Events", + "Application resources": "Application resources", + "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "No resources": "No resources", "There are no resources associated with the application.": "There are no resources associated with the application.", - "Application resources": "Application resources", - "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.": "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", "Name": "Name", "Namespace": "Namespace", "Sync Wave": "Sync Wave", @@ -108,11 +108,11 @@ "Edit ImageUpdater": "Edit ImageUpdater", "Delete ImageUpdater": "Delete ImageUpdater", "Error: Missing required route parameters": "Error: Missing required route parameters", + "True": "True", + "False": "False", "ImageUpdater details": "ImageUpdater details", "Ready": "Ready", "Whether the last reconciliation completed without errors.": "Whether the last reconciliation completed without errors.", - "True": "True", - "False": "False", "Applications Matched": "Applications Matched", "Number of applications matched by this ImageUpdater.": "Number of applications matched by this ImageUpdater.", "Images Managed": "Images Managed", @@ -311,7 +311,7 @@ "There are no Argo CD Applications in all projects.": "There are no Argo CD Applications in all projects.", "There was an error retrieving applications. Check your connection and reload the page.": "There was an error retrieving applications. Check your connection and reload the page.", "ApplicationSet Applications": "ApplicationSet Applications", - "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.", + "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.": "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.", "Revision": "修订", "No Argo CD ApplicationSets match the label filter": "No Argo CD ApplicationSets match the label filter", "Try removing the filter or selecting a different label to see more ApplicationSets.": "Try removing the filter or selecting a different label to see more ApplicationSets.", diff --git a/locales/zh/topology.json b/locales/zh/topology.json new file mode 100644 index 000000000..2a05caa36 --- /dev/null +++ b/locales/zh/topology.json @@ -0,0 +1,4 @@ +{ + "List view": "List view", + "Graph view": "Graph view" +} diff --git a/src/gitops/components/application/ApplicationResourcesTab.tsx b/src/gitops/components/application/ApplicationResourcesTab.tsx index ea2528954..b783250ae 100644 --- a/src/gitops/components/application/ApplicationResourcesTab.tsx +++ b/src/gitops/components/application/ApplicationResourcesTab.tsx @@ -2,42 +2,21 @@ import * as React from 'react'; import { RouteComponentProps } from 'react-router'; import classNames from 'classnames'; -import { useResourceActionsProvider } from '@gitops/hooks/useResourceActionsProvider'; -import HealthStatus from '@gitops/Statuses/HealthStatus'; -import SyncStatus from '@gitops/Statuses/SyncStatus'; -import ActionDropDown from '@gitops/utils/components/ActionDropDown/ActionDropDown'; import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; import { ApplicationKind, ApplicationResourceStatus } from '@gitops-models/ApplicationModel'; -import { - Action, - K8sGroupVersionKind, - ListPageFilter, - ResourceLink, - RowFilter, - RowFilterItem, - useK8sModel, - useListPageFilter, -} from '@openshift-console/dynamic-plugin-sdk'; -import { - EmptyState, - EmptyStateBody, - Flex, - FlexItem, - PageBody, - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; -import { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView'; -import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; -import { CubesIcon } from '@patternfly/react-icons'; -import { Tbody, Td, Tr } from '@patternfly/react-table'; +import { useK8sModel, useUserSettings } from '@openshift-console/dynamic-plugin-sdk'; +import { Flex, FlexItem, PageBody, PageSection, PageSectionVariants, Title } from '@patternfly/react-core'; import { ArgoServer, getArgoServer } from '../../utils/gitops'; import ArgoCDLink from '../shared/ArgoCDLink/ArgoCDLink'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; -import { ApplicationGraphView } from './graph/ApplicationGraphView'; +import ApplicationResourcesView, { useResourceColumnsDV } from './ApplicationResourcesView'; +import { + APPLICATION_RESOURCES_VIEW_SETTING_KEY, + ApplicationResourcesViewType, +} from './ApplicationResourcesViewType'; + +export { useResourceColumnsDV }; type ApplicationResourcesTabProps = RouteComponentProps<{ ns: string; @@ -48,8 +27,16 @@ type ApplicationResourcesTabProps = RouteComponentProps<{ const ApplicationResourcesTab: React.FC = ({ obj }) => { const [model] = useK8sModel({ group: 'route.openshift.io', version: 'v1', kind: 'Route' }); - const [argoServer, setArgoServer] = React.useState({ host: '', protocol: '' }); + const [savedViewType, setSavedViewType, viewSettingsLoaded] = + useUserSettings( + APPLICATION_RESOURCES_VIEW_SETTING_KEY, + ApplicationResourcesViewType.graph, + false, + ); + const [viewType, setViewType] = React.useState( + ApplicationResourcesViewType.graph, + ); React.useEffect(() => { (async () => { @@ -63,59 +50,35 @@ const ApplicationResourcesTab: React.FC = ({ obj } })(); }, [model, obj]); - let resources: ApplicationResourceStatus[]; - if (obj?.status?.resources) { - resources = obj?.status?.resources; - } else { - resources = []; - } - - const columnSortConfig = React.useMemo( - () => - ['name', 'namespace', 'sync-wave', 'sync-status', 'health-status', 'actions'].map((key) => ({ - key, - })), - [], - ); - - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); - const columnsDV = useResourceColumnsDV(getSortParams); - const sortedResources = React.useMemo(() => { - return sortData(resources, sortBy, direction); - }, [resources, sortBy, direction]); + React.useEffect(() => { + if (viewSettingsLoaded) { + setViewType(savedViewType ?? ApplicationResourcesViewType.graph); + } + }, [savedViewType, viewSettingsLoaded]); - // TODO: use alternate filter since it is deprecated. See DataTableView potentially - const resourceFilters = React.useMemo(() => filters(sortedResources), [sortedResources]); - const [data, filteredData, onFilterChange] = useListPageFilter(sortedResources, resourceFilters); + const resources: ApplicationResourceStatus[] = obj?.status?.resources ?? []; - const memoizedFilteredResources = React.useMemo(() => [...filteredData], [filteredData]); - const isEmptyResources = memoizedFilteredResources.length === 0; + const onViewChange = React.useCallback( + (newViewType: ApplicationResourcesViewType) => { + setViewType(newViewType); + setSavedViewType(newViewType); + }, + [setSavedViewType], + ); - const rows = useResourceRowsDV( - memoizedFilteredResources, - obj, + const argoBaseURL = argoServer.protocol + - '://' + - argoServer.host + - '/applications/' + - obj?.metadata?.namespace + - '/' + - obj?.metadata?.name, - ); + '://' + + argoServer.host + + '/applications/' + + obj?.metadata?.namespace + + '/' + + obj?.metadata?.name; + + if (!viewSettingsLoaded) { + return null; + } - const empty = ( - - - - - - {t('There are no resources associated with the application.')} - - - - - - ); return (
= ({ obj } {t('Application resources')} + {t( - "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", + "The graph and table views show health and sync status for the application's immediate resources only. Click the Argo CD Link to see the complete resource tree. Use the filter to filter resources based on status and kind.", )} - + - <> - {obj.metadata && ( - - - - - - - - - - - )} - + {obj?.metadata && ( + + )}
); }; -const sortData = ( - data: ApplicationResourceStatus[], - sortBy: string | undefined, - direction: 'asc' | 'desc' | undefined, -) => { - if (!sortBy || !direction) return data; - - return [...data].sort((a, b) => { - let aValue: any, bValue: any; - - switch (sortBy) { - case 'name': - aValue = a.name || ''; - bValue = b.name || ''; - break; - case 'namespace': - aValue = a.namespace || ''; - bValue = b.namespace || ''; - break; - case 'sync-wave': - aValue = a.syncWave || ''; - bValue = b.syncWave || ''; - break; - case 'sync-status': - aValue = a.status || ''; - bValue = b.status || ''; - break; - case 'health-status': - aValue = a.health?.status || ''; - bValue = b.health?.status || ''; - break; - default: - return 0; - } - - if (direction === 'asc') { - // eslint-disable-next-line no-nested-ternary - return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; - } else { - // eslint-disable-next-line no-nested-ternary - return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; - } - }); -}; - -export const useResourceColumnsDV = (getSortParams) => { - const columns: DataViewTh[] = [ - { - cell: t('Name'), - props: { - 'aria-label': 'name', - className: 'pf-m-width-25', - sort: getSortParams(0), - }, - }, - { - cell: t('Namespace'), - props: { - 'aria-label': 'namespace', - className: 'pf-m-width-20', - sort: getSortParams(1), - }, - }, - { - cell: t('Sync Wave'), - props: { - 'aria-label': 'sync wave', - className: 'pf-m-width-15', - sort: getSortParams(2), - }, - }, - { - cell: t('Sync Status'), - props: { - 'aria-label': 'sync status', - className: 'pf-m-width-15', - sort: getSortParams(3), - }, - }, - { - cell: t('Health Status'), - props: { - 'aria-label': 'health status', - className: 'pf-m-width-15', - sort: getSortParams(4), - }, - }, - { - cell: '', - props: { 'aria-label': 'actions' }, - }, - ]; - - return columns; -}; - -const useResourceRowsDV = ( - resources: ApplicationResourceStatus[], - obj: ApplicationKind, - argoBaseURL: string, -): DataViewTr[] => { - const rows: DataViewTr[] = []; - - resources.forEach((resource, index) => { - const gvk: K8sGroupVersionKind = { - version: resource.version, - group: resource.group, - kind: resource.kind, - }; - - rows.push([ - { - cell: ( -
- -
- ), - id: resource.name + '-' + index, - dataLabel: 'Name', - }, - { - cell: resource.namespace ? resource.namespace : '-', - id: resource.namespace, - dataLabel: 'Namespace', - }, - { - id: 'sync-wave-' + index, - cell: <>{resource.syncWave || '-'}, - dataLabel: 'Sync Order', - }, - { - id: 'sync-status-' + index, - cell: <>{resource.status ? : '-'}, - }, - { - id: 'health-status-' + index, - cell: ( - <> - {resource.health?.status && ( - - )} - {!resource.health?.status && '-'} - - ), - }, - { - id: 'actions-' + index, - cell: , - props: { style: { paddingTop: 8, paddingRight: 0, paddingLeft: 0, width: 10 } }, - }, - ]); - }); - return rows; -}; - -const ResourceActionsCell: React.FC<{ - resource: ApplicationResourceStatus; - app: ApplicationKind; - argoBaseURL: string; -}> = ({ resource, app, argoBaseURL }) => { - const actionList: [actions: Action[]] = useResourceActionsProvider(resource, app, argoBaseURL); - return ( -
- -
- ); -}; - -const filters = (resources: ApplicationResourceStatus[]): RowFilter[] => { - return [ - { - filterGroupName: t('Sync Status'), - type: 'resource-sync', - reducer: (resource) => (resource.status ? resource.status : 'No Sync Status'), - filter: (input, resource) => { - if (input.selected?.length) { - if (resource?.status) { - return input.selected.includes(resource.status); - } else { - return input.selected.includes('No Sync Status'); // The resource has no health status and the None filter is selected - } - } - return true; - }, - items: resources - .map((resource) => { - return { - id: resource.status ? resource.status : 'No Sync Status', - title: resource.status ? resource.status : 'No Sync Status', - }; - }) - .reduce(function (result: RowFilterItem[], resource: RowFilterItem) { - if (!result.some((item) => item.id === resource.id)) { - result.push(resource); - } - return result; - }, []), - }, - { - filterGroupName: t('Health Status'), - type: 'resource-health', - reducer: (resource) => (resource.health ? resource.health.status : 'None'), - filter: (input, resource) => { - if (input.selected?.length) { - if (resource?.health?.status) { - return input.selected.includes(resource.health.status); - } else if (input.selected.includes('None')) { - return true; - } - return false; - } - return true; - }, - items: resources - .map((resource) => { - return { - id: resource.health && resource.health.status ? resource.health.status : 'None', - title: resource.health && resource.health.status ? resource.health.status : 'None', - }; - }) - .reduce(function (result: RowFilterItem[], resource: RowFilterItem) { - if (!result.some((item) => item.id === resource.id)) { - result.push(resource); - } - return result; - }, []), - }, - { - filterGroupName: t('Kind'), - type: 'resource-kind', - reducer: (resource) => resource.kind, - filter: (input, resource) => { - if (input.selected?.length) { - return input.selected.includes(resource.kind); - } else { - return true; - } - }, - items: resources - .map((resource) => { - return { id: resource.kind, title: resource.kind }; - }) - .reduce(function (result: RowFilterItem[], resource: RowFilterItem) { - if (!result.some((item) => item.id === resource.id)) { - result.push(resource); - } - return result; - }, []), - }, - ]; -}; - export default ApplicationResourcesTab; diff --git a/src/gitops/components/application/ApplicationResourcesToolbar.tsx b/src/gitops/components/application/ApplicationResourcesToolbar.tsx new file mode 100644 index 000000000..564eed748 --- /dev/null +++ b/src/gitops/components/application/ApplicationResourcesToolbar.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import GitOpsViewSwitcher from '../shared/GitOpsViewSwitcher'; +import { GitOpsViewType } from '../shared/GitOpsViewType'; + +type ApplicationResourcesToolbarProps = { + viewType: GitOpsViewType; + onViewChange: (view: GitOpsViewType) => void; + isDisabled?: boolean; +}; + +const ApplicationResourcesToolbar: React.FC = (props) => ( + +); + +export default ApplicationResourcesToolbar; diff --git a/src/gitops/components/application/ApplicationResourcesView.tsx b/src/gitops/components/application/ApplicationResourcesView.tsx new file mode 100644 index 000000000..02e225f48 --- /dev/null +++ b/src/gitops/components/application/ApplicationResourcesView.tsx @@ -0,0 +1,402 @@ +import * as React from 'react'; + +import { useResourceActionsProvider } from '@gitops/hooks/useResourceActionsProvider'; +import HealthStatus from '@gitops/Statuses/HealthStatus'; +import SyncStatus from '@gitops/Statuses/SyncStatus'; +import ActionDropDown from '@gitops/utils/components/ActionDropDown/ActionDropDown'; +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { ApplicationKind, ApplicationResourceStatus } from '@gitops-models/ApplicationModel'; +import { + Action, + K8sGroupVersionKind, + ListPageFilter, + ResourceLink, + RowFilter, + RowFilterItem, + useListPageFilter, +} from '@openshift-console/dynamic-plugin-sdk'; +import { EmptyState, EmptyStateBody, Flex, FlexItem, Stack, StackItem } from '@patternfly/react-core'; +import { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView'; +import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; +import { CubesIcon } from '@patternfly/react-icons'; +import { Tbody, Td, Tr } from '@patternfly/react-table'; + +import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; + +import ApplicationResourcesToolbar from './ApplicationResourcesToolbar'; +import { ApplicationGraphView } from './graph/ApplicationGraphView'; +import { ApplicationResourcesViewType } from './ApplicationResourcesViewType'; + +import '../shared/GitOpsGraphListView.scss'; + +type ApplicationResourcesViewProps = { + application: ApplicationKind; + resources: ApplicationResourceStatus[]; + viewType: ApplicationResourcesViewType; + onViewChange: (view: ApplicationResourcesViewType) => void; + argoBaseURL: string; +}; + +const ApplicationResourcesView: React.FC = ({ + application, + resources, + viewType, + onViewChange, + argoBaseURL, +}) => { + const columnSortConfig = React.useMemo( + () => + ['name', 'namespace', 'sync-wave', 'sync-status', 'health-status', 'actions'].map((key) => ({ + key, + })), + [], + ); + + const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const columnsDV = useResourceColumnsDV(getSortParams); + const sortedResources = React.useMemo( + () => sortData(resources, sortBy, direction), + [resources, sortBy, direction], + ); + + const resourceFilters = React.useMemo(() => filters(sortedResources), [sortedResources]); + const [data, filteredResources, onFilterChange] = useListPageFilter( + sortedResources, + resourceFilters, + ); + + const isEmptyResources = filteredResources.length === 0; + const rows = useResourceRowsDV(filteredResources, application, argoBaseURL); + const isListView = viewType === ApplicationResourcesViewType.list; + + const empty = ( + + + + + + {t('There are no resources associated with the application.')} + + + + + + ); + + return ( +
+ + + + + + + + + + + + +
+ {isListView ? ( + + ) : ( +
+ +
+ )} +
+
+
+
+ ); +}; + +const sortData = ( + data: ApplicationResourceStatus[], + sortBy: string | undefined, + direction: 'asc' | 'desc' | undefined, +) => { + if (!sortBy || !direction) return data; + + return [...data].sort((a, b) => { + let aValue: any, bValue: any; + + switch (sortBy) { + case 'name': + aValue = a.name || ''; + bValue = b.name || ''; + break; + case 'namespace': + aValue = a.namespace || ''; + bValue = b.namespace || ''; + break; + case 'sync-wave': + aValue = a.syncWave || ''; + bValue = b.syncWave || ''; + break; + case 'sync-status': + aValue = a.status || ''; + bValue = b.status || ''; + break; + case 'health-status': + aValue = a.health?.status || ''; + bValue = b.health?.status || ''; + break; + default: + return 0; + } + + if (direction === 'asc') { + // eslint-disable-next-line no-nested-ternary + return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; + } else { + // eslint-disable-next-line no-nested-ternary + return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; + } + }); +}; + +export const useResourceColumnsDV = (getSortParams) => { + const columns: DataViewTh[] = [ + { + cell: t('Name'), + props: { + 'aria-label': 'name', + className: 'pf-m-width-25', + sort: getSortParams(0), + }, + }, + { + cell: t('Namespace'), + props: { + 'aria-label': 'namespace', + className: 'pf-m-width-20', + sort: getSortParams(1), + }, + }, + { + cell: t('Sync Wave'), + props: { + 'aria-label': 'sync wave', + className: 'pf-m-width-15', + sort: getSortParams(2), + }, + }, + { + cell: t('Sync Status'), + props: { + 'aria-label': 'sync status', + className: 'pf-m-width-15', + sort: getSortParams(3), + }, + }, + { + cell: t('Health Status'), + props: { + 'aria-label': 'health status', + className: 'pf-m-width-15', + sort: getSortParams(4), + }, + }, + { + cell: '', + props: { 'aria-label': 'actions' }, + }, + ]; + + return columns; +}; + +const useResourceRowsDV = ( + resources: ApplicationResourceStatus[], + obj: ApplicationKind, + argoBaseURL: string, +): DataViewTr[] => { + const rows: DataViewTr[] = []; + + resources.forEach((resource, index) => { + const gvk: K8sGroupVersionKind = { + version: resource.version, + group: resource.group, + kind: resource.kind, + }; + + rows.push([ + { + cell: ( +
+ +
+ ), + id: resource.name + '-' + index, + dataLabel: 'Name', + }, + { + cell: resource.namespace ? resource.namespace : '-', + id: resource.namespace, + dataLabel: 'Namespace', + }, + { + id: 'sync-wave-' + index, + cell: <>{resource.syncWave || '-'}, + dataLabel: 'Sync Order', + }, + { + id: 'sync-status-' + index, + cell: <>{resource.status ? : '-'}, + }, + { + id: 'health-status-' + index, + cell: ( + <> + {resource.health?.status && ( + + )} + {!resource.health?.status && '-'} + + ), + }, + { + id: 'actions-' + index, + cell: , + props: { style: { paddingTop: 8, paddingRight: 0, paddingLeft: 0, width: 10 } }, + }, + ]); + }); + return rows; +}; + +const ResourceActionsCell: React.FC<{ + resource: ApplicationResourceStatus; + app: ApplicationKind; + argoBaseURL: string; +}> = ({ resource, app, argoBaseURL }) => { + const actionList: [actions: Action[]] = useResourceActionsProvider(resource, app, argoBaseURL); + return ( +
+ +
+ ); +}; + +const filters = (resources: ApplicationResourceStatus[]): RowFilter[] => { + return [ + { + filterGroupName: t('Sync Status'), + type: 'resource-sync', + reducer: (resource) => (resource.status ? resource.status : 'No Sync Status'), + filter: (input, resource) => { + if (input.selected?.length) { + if (resource?.status) { + return input.selected.includes(resource.status); + } else { + return input.selected.includes('No Sync Status'); + } + } + return true; + }, + items: resources + .map((resource) => { + return { + id: resource.status ? resource.status : 'No Sync Status', + title: resource.status ? resource.status : 'No Sync Status', + }; + }) + .reduce(function (result: RowFilterItem[], resource: RowFilterItem) { + if (!result.some((item) => item.id === resource.id)) { + result.push(resource); + } + return result; + }, []), + }, + { + filterGroupName: t('Health Status'), + type: 'resource-health', + reducer: (resource) => (resource.health ? resource.health.status : 'None'), + filter: (input, resource) => { + if (input.selected?.length) { + if (resource?.health?.status) { + return input.selected.includes(resource.health.status); + } else if (input.selected.includes('None')) { + return true; + } + return false; + } + return true; + }, + items: resources + .map((resource) => { + return { + id: resource.health && resource.health.status ? resource.health.status : 'None', + title: resource.health && resource.health.status ? resource.health.status : 'None', + }; + }) + .reduce(function (result: RowFilterItem[], resource: RowFilterItem) { + if (!result.some((item) => item.id === resource.id)) { + result.push(resource); + } + return result; + }, []), + }, + { + filterGroupName: t('Kind'), + type: 'resource-kind', + reducer: (resource) => resource.kind, + filter: (input, resource) => { + if (input.selected?.length) { + return input.selected.includes(resource.kind); + } else { + return true; + } + }, + items: resources + .map((resource) => { + return { id: resource.kind, title: resource.kind }; + }) + .reduce(function (result: RowFilterItem[], resource: RowFilterItem) { + if (!result.some((item) => item.id === resource.id)) { + result.push(resource); + } + return result; + }, []), + }, + ]; +}; + +export default ApplicationResourcesView; diff --git a/src/gitops/components/application/ApplicationResourcesViewType.ts b/src/gitops/components/application/ApplicationResourcesViewType.ts new file mode 100644 index 000000000..7a8d2cf66 --- /dev/null +++ b/src/gitops/components/application/ApplicationResourcesViewType.ts @@ -0,0 +1,4 @@ +export { + GitOpsViewType as ApplicationResourcesViewType, + APPLICATION_RESOURCES_VIEW_SETTING_KEY, +} from '../shared/GitOpsViewType'; diff --git a/src/gitops/components/application/graph/nodes/ApplicationNode.tsx b/src/gitops/components/application/graph/nodes/ApplicationNode.tsx index 2180cbf76..ce152fb6e 100644 --- a/src/gitops/components/application/graph/nodes/ApplicationNode.tsx +++ b/src/gitops/components/application/graph/nodes/ApplicationNode.tsx @@ -43,7 +43,7 @@ const ApplicationHealthStatusIcon = ({ status }: { status: HealthStatus }) => { let icon = null; switch (status) { case HealthStatus.HEALTHY: - icon = ; + icon = ; break; case HealthStatus.MISSING: icon = ; diff --git a/src/gitops/components/shared/ApplicationList.tsx b/src/gitops/components/shared/ApplicationList.tsx index 787ddc6fd..33b249c7c 100644 --- a/src/gitops/components/shared/ApplicationList.tsx +++ b/src/gitops/components/shared/ApplicationList.tsx @@ -36,8 +36,8 @@ import SyncStatusFragment from '../../Statuses/SyncStatus'; import ActionsDropdown from '../../utils/components/ActionDropDown/ActionDropDown'; import { isApplicationRefreshing } from '../../utils/gitops'; import { modelToGroupVersionKind, modelToRef } from '../../utils/utils'; -import { ApplicationSetGraphView } from '../appset/graph/ApplicationSetGraphView'; +import ApplicationSetApplicationsView from './ApplicationSetApplicationsView'; import { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, @@ -111,8 +111,7 @@ const ApplicationList: React.FC = ({ [listAllNamespaces, namespace], ); - const { searchParams, sortBy, direction, getSortParams } = - useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); // Get search query from URL parameters const searchQuery = searchParams.get('q') || ''; @@ -132,6 +131,7 @@ const ApplicationList: React.FC = ({ // TODO: use alternate filter since it is deprecated. See DataTableView potentially // PatternFly filters work on owned apps only (the dataset that will be displayed) const filters = getFilters(t); + // const filters = React.useMemo(() => getFilters(t), [t]); const [data, filteredData, onFilterChange] = useListPageFilter(ownedApps, filters); // Filter by search query if present (after other filters) @@ -230,33 +230,17 @@ const ApplicationList: React.FC = ({ {/* Show an AppSet specific title if showTitle is undefined. We don't want a duplicate title from above */} {appset && ( - {/* {showTitle == undefined && ( */} {t('ApplicationSet Applications')} - {/* )} */} {t( - "The graph and table views show the ApplicationSet's applications. Use the filter below the graph to filter applications based on their health and sync status.", + "The graph and table views show the ApplicationSet's applications. Use the filter to filter applications based on their health and sync status.", )} - - - )} - {!hideNameLabelFilters && hasOwnedApplications && ( + {!appset && !hideNameLabelFilters && hasOwnedApplications && ( = ({ nameFilterPlaceholder={t('plugin__gitops-plugin~Search by name...')} /> )} - + {appset && ( + + )} + {!appset && ( + + )} ); diff --git a/src/gitops/components/shared/ApplicationSetApplicationsView.tsx b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx new file mode 100644 index 000000000..f48f2ae7a --- /dev/null +++ b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; + +import { ApplicationSetKind } from '@gitops/models/ApplicationSetModel'; +import { ApplicationKind } from '@gitops/models/ApplicationModel'; +import { ListPageFilter, RowFilter, useUserSettings } from '@openshift-console/dynamic-plugin-sdk'; +import { Flex, FlexItem, Stack, StackItem } from '@patternfly/react-core'; +import { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView'; +import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/DataViewTable'; + +import { ApplicationSetGraphView } from '../appset/graph/ApplicationSetGraphView'; + +import { GitOpsDataViewTable } from './DataView'; +import GitOpsViewSwitcher from './GitOpsViewSwitcher'; +import { + APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY, + GitOpsViewType, +} from './GitOpsViewType'; + +import './GitOpsGraphListView.scss'; + +type ApplicationSetApplicationsViewProps = { + applicationSet: ApplicationSetKind; + ownedApps: ApplicationKind[]; + filteredApplications: ApplicationKind[]; + hideNameLabelFilters?: boolean; + hasOwnedApplications: boolean; + rowFilters: RowFilter[]; + listPageFilterData: ApplicationKind[]; + onFilterChange: (type: string, value: { selected?: string[]; all?: string[] }) => void; + nameFilterPlaceholder: string; + loaded: boolean; + columns: DataViewTh[]; + rows: DataViewTr[]; + emptyState: React.ReactNode; + errorState?: React.ReactNode; + isError?: boolean; + isEmpty: boolean; +}; + +const ApplicationSetApplicationsView: React.FC = ({ + applicationSet, + ownedApps, + filteredApplications, + hideNameLabelFilters, + hasOwnedApplications, + rowFilters, + listPageFilterData, + onFilterChange, + nameFilterPlaceholder, + loaded, + columns, + rows, + emptyState, + errorState, + isError, + isEmpty, +}) => { + const [savedViewType, setSavedViewType, viewSettingsLoaded] = useUserSettings( + APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY, + GitOpsViewType.graph, + false, + ); + const [viewType, setViewType] = React.useState(GitOpsViewType.graph); + + React.useEffect(() => { + if (viewSettingsLoaded) { + setViewType(savedViewType ?? GitOpsViewType.graph); + } + }, [savedViewType, viewSettingsLoaded]); + + const onViewChange = React.useCallback( + (newViewType: GitOpsViewType) => { + setViewType(newViewType); + setSavedViewType(newViewType); + }, + [setSavedViewType], + ); + + const isListView = viewType === GitOpsViewType.list; + + if (!viewSettingsLoaded) { + return null; + } + + return ( +
+ + + + + {!hideNameLabelFilters && hasOwnedApplications && ( + + )} + + + + + + + +
+ {isListView ? ( + + ) : ( +
+ +
+ )} +
+
+
+
+ ); +}; + +export default ApplicationSetApplicationsView; diff --git a/src/gitops/components/shared/GitOpsGraphListView.scss b/src/gitops/components/shared/GitOpsGraphListView.scss new file mode 100644 index 000000000..6d71a9ce6 --- /dev/null +++ b/src/gitops/components/shared/GitOpsGraphListView.scss @@ -0,0 +1,53 @@ +.gitops-graph-list-view { + display: flex; + flex-direction: column; + margin-top: var(--pf-t--global--spacer--md); + + &__content { + min-height: 0; + } + + &__panel, + &__list-panel { + min-height: 1000px; + } + + &__list-panel { + display: flex; + flex-direction: column; + } + + &__toolbar-row { + margin-bottom: var(--pf-t--global--spacer--sm); + } + + &__header { + display: flex; + justify-content: flex-end; + flex-shrink: 0; + padding-right: 30px; + } + + &__graph { + box-sizing: border-box; + width: 95%; + height: 1000px; + margin: 30px; + border: 1px solid gray; + display: flex; + flex-direction: column; + + > .gitops-topology-view { + flex: 1; + height: 100%; + min-height: 0; + } + + .gitops-topology-view, + .pf-topology-container, + .pf-topology-content { + height: 100%; + width: 100%; + } + } +} diff --git a/src/gitops/components/shared/GitOpsViewSwitcher.tsx b/src/gitops/components/shared/GitOpsViewSwitcher.tsx new file mode 100644 index 000000000..58621d56c --- /dev/null +++ b/src/gitops/components/shared/GitOpsViewSwitcher.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { Button, Icon, Tooltip } from '@patternfly/react-core'; +import { ListIcon, TopologyIcon } from '@patternfly/react-icons'; + +import { GitOpsViewType } from './GitOpsViewType'; + +type GitOpsViewSwitcherProps = { + viewType: GitOpsViewType; + onViewChange: (view: GitOpsViewType) => void; + isDisabled?: boolean; + testId?: string; +}; + +const GitOpsViewSwitcher: React.FC = ({ + viewType, + onViewChange, + isDisabled = false, + testId = 'gitops-view-switcher', +}) => { + const showGraphView = viewType === GitOpsViewType.graph; + const viewChangeTooltipContent = showGraphView + ? t('topology~List view') + : t('topology~Graph view'); + + return ( + +