From 52edc505e6b4ef71b651b109f87c559734b9c869 Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Mon, 29 Jun 2026 08:25:11 -0400 Subject: [PATCH] Implement Topology's List/Graph switch toggle (#10018) Signed-off-by: Keith Chong --- locales/en/plugin__gitops-plugin.json | 12 +- locales/ja/plugin__gitops-plugin.json | 12 +- locales/ko/plugin__gitops-plugin.json | 12 +- locales/zh/plugin__gitops-plugin.json | 12 +- .../application/ApplicationResourcesTab.tsx | 430 +++--------------- .../ApplicationResourcesToolbar.tsx | 16 + .../application/ApplicationResourcesView.tsx | 409 +++++++++++++++++ .../ApplicationResourcesViewType.ts | 4 + .../graph/nodes/ApplicationNode.tsx | 2 +- .../components/shared/ApplicationList.tsx | 61 +-- .../shared/ApplicationSetApplicationsView.tsx | 146 ++++++ .../shared/GitOpsGraphListView.scss | 53 +++ .../components/shared/GitOpsViewSwitcher.tsx | 46 ++ .../components/shared/GitOpsViewType.ts | 10 + yarn.lock | 189 +------- 15 files changed, 814 insertions(+), 600 deletions(-) create mode 100644 src/gitops/components/application/ApplicationResourcesToolbar.tsx create mode 100644 src/gitops/components/application/ApplicationResourcesView.tsx create mode 100644 src/gitops/components/application/ApplicationResourcesViewType.ts create mode 100644 src/gitops/components/shared/ApplicationSetApplicationsView.tsx create mode 100644 src/gitops/components/shared/GitOpsGraphListView.scss create mode 100644 src/gitops/components/shared/GitOpsViewSwitcher.tsx create mode 100644 src/gitops/components/shared/GitOpsViewType.ts diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index ffdbf1a23..b532aea5a 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.", @@ -332,6 +332,8 @@ "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.", "This details page is under tech preview, but not necessarily the resource it represents": "This details page is under tech preview, but not necessarily the resource it represents", + "List view": "List view", + "Graph view": "Graph view", "Sync": "Sync", "Stop": "Stop", "Refresh": "Refresh", diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index 2430d79c3..9bf0165f9 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.", @@ -332,6 +332,8 @@ "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.", "This details page is under tech preview, but not necessarily the resource it represents": "This details page is under tech preview, but not necessarily the resource it represents", + "List view": "List view", + "Graph view": "Graph view", "Sync": "Sync", "Stop": "Stop", "Refresh": "Refresh", diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index 9b2a17804..b23d99ce1 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.", @@ -332,6 +332,8 @@ "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.", "This details page is under tech preview, but not necessarily the resource it represents": "This details page is under tech preview, but not necessarily the resource it represents", + "List view": "List view", + "Graph view": "Graph view", "Sync": "Sync", "Stop": "Stop", "Refresh": "Refresh", diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index 873866551..e8ce9c289 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.", @@ -332,6 +332,8 @@ "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.": "Time is a wrapper around time. Time which supports correct marshaling to YAML and JSON.", "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.": "Owner references link this resource to its parent object. For example, Applications generated by an ApplicationSet will have that ApplicationSet as their owner. This relationship enables proper resource lifecycle management and garbage collection.", "This details page is under tech preview, but not necessarily the resource it represents": "This details page is under tech preview, but not necessarily the resource it represents", + "List view": "List view", + "Graph view": "Graph view", "Sync": "Sync", "Stop": "Stop", "Refresh": "Refresh", diff --git a/src/gitops/components/application/ApplicationResourcesTab.tsx b/src/gitops/components/application/ApplicationResourcesTab.tsx index ea2528954..cf748abdd 100644 --- a/src/gitops/components/application/ApplicationResourcesTab.tsx +++ b/src/gitops/components/application/ApplicationResourcesTab.tsx @@ -2,25 +2,10 @@ 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 { useK8sModel, useUserSettings } from '@openshift-console/dynamic-plugin-sdk'; import { - Action, - K8sGroupVersionKind, - ListPageFilter, - ResourceLink, - RowFilter, - RowFilterItem, - useK8sModel, - useListPageFilter, -} from '@openshift-console/dynamic-plugin-sdk'; -import { - EmptyState, - EmptyStateBody, Flex, FlexItem, PageBody, @@ -28,16 +13,17 @@ import { 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 { 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 +34,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 +57,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..a069b7cc5 --- /dev/null +++ b/src/gitops/components/application/ApplicationResourcesView.tsx @@ -0,0 +1,409 @@ +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 { ApplicationGraphView } from './graph/ApplicationGraphView'; +import ApplicationResourcesToolbar from './ApplicationResourcesToolbar'; +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..3c9eabc06 --- /dev/null +++ b/src/gitops/components/application/ApplicationResourcesViewType.ts @@ -0,0 +1,4 @@ +export { + APPLICATION_RESOURCES_VIEW_SETTING_KEY, + GitOpsViewType as ApplicationResourcesViewType, +} 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..d12df379a 100644 --- a/src/gitops/components/shared/ApplicationList.tsx +++ b/src/gitops/components/shared/ApplicationList.tsx @@ -36,12 +36,12 @@ 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 { ShowOperandsInAllNamespacesRadioGroup, useShowOperandsInAllNamespaces, } from './AllNamespaces'; +import ApplicationSetApplicationsView from './ApplicationSetApplicationsView'; import { GitOpsDataViewTable, useGitOpsDataViewSort } from './DataView'; interface ApplicationProps { @@ -132,6 +132,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 +231,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..c3acb54dd --- /dev/null +++ b/src/gitops/components/shared/ApplicationSetApplicationsView.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; + +import { ApplicationKind } from '@gitops/models/ApplicationModel'; +import { ApplicationSetKind } from '@gitops/models/ApplicationSetModel'; +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..a09cc05d9 --- /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('plugin__gitops-plugin~List view') + : t('plugin__gitops-plugin~Graph view'); + + return ( + +