diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index 2e1090283..bf43198f5 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -116,16 +116,17 @@ "Delete {{x}}": "Delete {{x}}", "Edit {{x}}": "Edit {{x}}", "View in Argo CD": "View in Argo CD", + "View Details": "View Details", "Edit Application": "Edit Application", "Delete Application": "Delete Application", "Show {{x}}": "Show {{x}}", "Hide {{x}}": "Hide {{x}}", + "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}": "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}", "Group resources of the same kind into one node": "Group resources of the same kind into one node", "Group Nodes": "Group Nodes", "There is no health status for this resource": "There is no health status for this resource", "Sync Unknown": "Sync Unknown", "One or more resources are in Progressing state": "One or more resources are in Progressing state", - "Go to application": "Go to application", "Step {{x}}": "Step {{x}}", "Step: unmatched": "Step: unmatched", "There is no history associated with the application.": "There is no history associated with the application.", diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index ba4a09539..be8e4aee2 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -116,16 +116,17 @@ "Delete {{x}}": "Delete {{x}}", "Edit {{x}}": "Edit {{x}}", "View in Argo CD": "View in Argo CD", + "View Details": "View Details", "Edit Application": "Edit Application", "Delete Application": "Delete Application", "Show {{x}}": "Show {{x}}", "Hide {{x}}": "Hide {{x}}", + "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}": "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}", "Group resources of the same kind into one node": "Group resources of the same kind into one node", "Group Nodes": "Group Nodes", "There is no health status for this resource": "There is no health status for this resource", "Sync Unknown": "Sync Unknown", "One or more resources are in Progressing state": "One or more resources are in Progressing state", - "Go to application": "Go to application", "Step {{x}}": "Step {{x}}", "Step: unmatched": "Step: unmatched", "There is no history associated with the application.": "There is no history associated with the application.", diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index b9a275972..bc0731941 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -116,16 +116,17 @@ "Delete {{x}}": "Delete {{x}}", "Edit {{x}}": "Edit {{x}}", "View in Argo CD": "View in Argo CD", + "View Details": "View Details", "Edit Application": "Edit Application", "Delete Application": "Delete Application", "Show {{x}}": "Show {{x}}", "Hide {{x}}": "Hide {{x}}", + "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}": "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}", "Group resources of the same kind into one node": "Group resources of the same kind into one node", "Group Nodes": "Group Nodes", "There is no health status for this resource": "There is no health status for this resource", "Sync Unknown": "Sync Unknown", "One or more resources are in Progressing state": "One or more resources are in Progressing state", - "Go to application": "Go to application", "Step {{x}}": "Step {{x}}", "Step: unmatched": "Step: unmatched", "There is no history associated with the application.": "There is no history associated with the application.", diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index 1d8b344a1..d5211346c 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -116,16 +116,17 @@ "Delete {{x}}": "Delete {{x}}", "Edit {{x}}": "Edit {{x}}", "View in Argo CD": "View in Argo CD", + "View Details": "View Details", "Edit Application": "Edit Application", "Delete Application": "Delete Application", "Show {{x}}": "Show {{x}}", "Hide {{x}}": "Hide {{x}}", + "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}": "Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}", "Group resources of the same kind into one node": "Group resources of the same kind into one node", "Group Nodes": "Group Nodes", "There is no health status for this resource": "There is no health status for this resource", "Sync Unknown": "Sync Unknown", "One or more resources are in Progressing state": "One or more resources are in Progressing state", - "Go to application": "Go to application", "Step {{x}}": "Step {{x}}", "Step: unmatched": "Step: unmatched", "There is no history associated with the application.": "There is no history associated with the application.", diff --git a/src/gitops/components/application/graph/ApplicationGraphView.scss b/src/gitops/components/application/graph/ApplicationGraphView.scss index 4a5aa4bd6..34d5cdc64 100644 --- a/src/gitops/components/application/graph/ApplicationGraphView.scss +++ b/src/gitops/components/application/graph/ApplicationGraphView.scss @@ -32,7 +32,53 @@ // .pf-topology__edge__background { // stroke: var(--pf-t--global--dark--background--color--100); // } - + + .pf-topology__node.pf-m-selected { + .pf-topology__node__label__badge > rect { + stroke-width: 1; + } + .pf-topology__node__action-icon__icon .pf-v6-svg{ + stroke: var(--pf-topology__node--Color); + fill: var(--pf-topology__node__label__background--Stroke); + } + .gitops-node-layout { + .pf-topology__node__action-icon__icon .pf-v6-svg{ + stroke: var(--pf-t--global--border--color--default); + fill: var(--pf-topology__node__action-icon__icon--Color); + } + } + } + + .gitops-resource-node-label-badge-opaque { + opacity: 0; + } + + .gitops-resource-node-label { + .pf-topology__node__separator { + opacity: 0; + } + + .pf-topology__node__label__background { + width: 0px; + height: 0px; + stroke: var(--pf-topology__node--Color); + fill: var(--pf-topology__node--Color); + } + } + + .gitops-resource-node-menu { + transform: translate(243px,10px); + } + + .gitops-resource-group-node-menu { + transform: translate(243px,25px); + } + + .gitops-application-node-menu { + transform: translate(263px,25px); + fill: var(--pf-topology__node__label__background--Stroke); + } + .step-edge { &.step-edge-healthy { stroke: var(--pf-v5-global--success-color--100); @@ -74,7 +120,7 @@ padding-right: 6px; } - .pf-v6-c-toolbar__item:has(button#setting-owner-reference-layout) { + .pf-v6-c-toolbar__item:has(button#toggle-node-layout) { padding-left: 8px; border-left: 2px solid var(--pf-t--global--border--color--default); } diff --git a/src/gitops/components/application/graph/ApplicationGraphView.tsx b/src/gitops/components/application/graph/ApplicationGraphView.tsx index f44342ef8..7cc6bef0b 100644 --- a/src/gitops/components/application/graph/ApplicationGraphView.tsx +++ b/src/gitops/components/application/graph/ApplicationGraphView.tsx @@ -18,7 +18,7 @@ import { useUserSettings, } from '@openshift-console/dynamic-plugin-sdk'; import { useK8sModel } from '@openshift-console/dynamic-plugin-sdk/lib/utils/k8s/hooks/useK8sModel'; -import { ObjectGroupIcon } from '@patternfly/react-icons'; +import { ObjectGroupIcon, ToggleOffIcon, ToggleOnIcon } from '@patternfly/react-icons'; import { action, ComponentFactory, @@ -59,14 +59,19 @@ import { GraphResourceMenuItem } from './hooks/GraphResourceMenuItems'; import { ApplicationNode } from './nodes/ApplicationNode'; import { ResourceGroupNode } from './nodes/ResourceGroupNode'; import { ResourceNode } from './nodes/ResourceNode'; -import { getInitialEdges, getInitialNodes } from './graph-utils'; +import { + getInitialEdges, + getInitialNodes, + getResourceMapKey, + getResourcePathForResource, +} from './graph-utils'; import './ApplicationGraphView.scss'; const customLayoutFactory: LayoutFactory = (type: string, graph: Graph): Layout | undefined => { return new DagreLayout(graph, { rankdir: 'LR', - ranksep: 1, + ranksep: 0, nodesep: 0, edgesep: 0, ranker: 'network-simplex', @@ -122,7 +127,6 @@ const customComponentFactory = ) { return ; } - // For other actions that don't need resource-specific hooks return ( {label} @@ -146,26 +153,14 @@ const customComponentFactory = graphElement.getData().kind === 'AppProject' || graphElement.getData().kind === 'Namespace' ) { - return createContextMenuItems2(graphElement, [ - t('Edit labels'), - t('Edit annotations'), - t('Edit {{x}}', { - x: graphElement.getData().kind, - }), - '-', - t('View in Argo CD'), - ]); + return createContextMenuItems2(graphElement, [t('View Details'), t('View in Argo CD')]); } else { return createContextMenuItems2(graphElement, [ - t('Edit labels'), - t('Edit annotations'), - t('Edit {{x}}', { - x: graphElement.getData().kind, - }), t('Delete {{x}}', { x: graphElement.getData().kind, }), '-', + t('View Details'), t('View in Argo CD'), ]); } @@ -308,6 +303,11 @@ export const ApplicationGraphView: React.FC<{ false, false, ); + const [resourceNodeLayout, setResourceNodeLayout] = useUserSettings( + 'redhat.gitops.resourceNodeLayout', + true, + false, + ); const [argoServer, setArgoServer] = React.useState({ host: '', protocol: '' }); const hrefRef = React.useRef(''); React.useEffect(() => { @@ -357,12 +357,26 @@ export const ApplicationGraphView: React.FC<{ return newController; }, []); + const resourcePaths = React.useMemo(() => { + const map = new Map(); + resources.forEach((resource) => { + const mapKey = getResourceMapKey(resource); + if (resource.health?.status !== HealthStatus.MISSING) { + map.set(mapKey, getResourcePathForResource(resource, allK8sModels)); + } else { + map.set(mapKey, ''); + } + }); + return map; + }, [resources, allK8sModels]); const initialNodes = getInitialNodes( application, resources, allK8sModels, groupNodeState, groupNodeStates, + resourceNodeLayout, + resourcePaths, ); const initialEdges = getInitialEdges(application, initialNodes, groupNodeState); const nodes = [...initialNodes]; @@ -373,12 +387,16 @@ export const ApplicationGraphView: React.FC<{ // Track the previous node count to detect structural changes const previousNodeCountRef = React.useRef(0); const groupNodeRef = React.useRef(groupNodeState); + const resourceNodeLayoutRef = React.useRef(resourceNodeLayout); const currentNodeCount = nodes.length; const previousNodeCount = previousNodeCountRef.current; const previousGroupNodeState = groupNodeRef.current; + const previousResourceNodeLayout = resourceNodeLayoutRef.current; const isStructuralChange = - currentNodeCount !== previousNodeCount || previousGroupNodeState != groupNodeState; + currentNodeCount !== previousNodeCount || + previousGroupNodeState != groupNodeState || + previousResourceNodeLayout != resourceNodeLayout; if (isStructuralChange || previousNodeCount === 0) { // Structural change: Create model WITH layout @@ -395,6 +413,7 @@ export const ApplicationGraphView: React.FC<{ controller.fromModel(modelWithLayout, false); previousNodeCountRef.current = currentNodeCount; groupNodeRef.current = groupNodeState; + resourceNodeLayoutRef.current = resourceNodeLayout; } else { // Data change only: Update ONLY changed nodes (no layout, no position changes) let updateCount = 0; @@ -462,6 +481,22 @@ export const ApplicationGraphView: React.FC<{ controller.getGraph().layout(); }), customButtons: [ + { + id: 'toggle-node-layout', + icon: resourceNodeLayout ? : , + tooltip: t( + 'Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}', + { x: resourceNodeLayout ? 'Argo CD' : 'OpenShift' }, + ), + ariaLabel: t( + 'Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}', + { x: resourceNodeLayout ? 'Argo CD' : 'OpenShift' }, + ), + callback: () => { + setResourceNodeLayout(!resourceNodeLayout); + controller.getGraph().layout(); + }, + }, { id: 'use-group-nodes', icon: , diff --git a/src/gitops/components/application/graph/graph-utils.tsx b/src/gitops/components/application/graph/graph-utils.tsx index a457231fc..36b60d30a 100644 --- a/src/gitops/components/application/graph/graph-utils.tsx +++ b/src/gitops/components/application/graph/graph-utils.tsx @@ -7,8 +7,13 @@ import { kindToAbbr, NODE_DIAMETER, } from '@gitops/components/graph/utils'; -import { ApplicationKind, ApplicationResourceStatus } from '@gitops/models/ApplicationModel'; +import { + ApplicationKind, + ApplicationModel, + ApplicationResourceStatus, +} from '@gitops/models/ApplicationModel'; import { HealthStatus, SyncStatus } from '@gitops/utils/constants'; +import { resourcePathFromModel } from '@gitops/utils/utils'; import { K8sModel } from '@openshift-console/dynamic-plugin-sdk'; import { EdgeStyle, @@ -24,7 +29,10 @@ const NODE_TYPE_APPLICATION = 'application-node'; const NODE_TYPE_APPLICATION_LABEL = 'Application'; // Map application health status with topology node status -const createApplicationNode = (application: ApplicationKind): NodeModel => { +const createApplicationNode = ( + application: ApplicationKind, + resourceNodeLayout: boolean, +): NodeModel => { const nodeStatus = getTopologyNodeStatus(application.status?.health?.status); return { id: @@ -34,7 +42,7 @@ const createApplicationNode = (application: ApplicationKind): NodeModel => { '-' + application?.metadata?.namespace, type: NODE_TYPE_APPLICATION, - label: NODE_TYPE_APPLICATION_LABEL, + label: resourceNodeLayout ? ' ' : NODE_TYPE_APPLICATION_LABEL, status: nodeStatus, width: APP_NODE_WIDTH, height: APP_NODE_HEIGHT, @@ -47,7 +55,9 @@ const createApplicationNode = (application: ApplicationKind): NodeModel => { badgeBorderColor: RESOURCE_COLORS.get( RESOURCE_BADGE_COLORS.get('.co-m-resource-' + application?.kind.toLowerCase()), ), + badgeTextColor: 'white', rank: 0, + resourceNodeLayout: resourceNodeLayout, nodeStatus: nodeStatus, resourceHealthStatus: application?.status?.health?.status, appHealthStatus: application?.status?.health?.status, @@ -105,6 +115,7 @@ const createGroupResourceNode = ( resources: ApplicationResourceStatus[], allK8sModels: { [key: string]: K8sModel }, resourceGroupExpandState: boolean, + resourceNodeLayout: boolean, ): Map => { const resourceCount = resources.filter((res) => res.kind === resource.kind).length; const resourceHealthyCount = resource.health @@ -186,7 +197,7 @@ const createGroupResourceNode = ( groupResourceNode = { id: kind + '-node-group', type: 'node-group', - label: allK8sModels[kind]?.labelPlural || kind + 's', + label: resourceNodeLayout ? ' ' : allK8sModels[kind]?.labelPlural || kind + 's', shape: NodeShape.stadium, status: groupStatus, width: 280, @@ -196,6 +207,7 @@ const createGroupResourceNode = ( kind: kind, // Group's kind kindPlural: allK8sModels[kind]?.labelPlural || kind + 's', resourceGroupExpandState: resourceGroupExpandState, + resourceNodeLayout: resourceNodeLayout, nodeStatus: groupStatus, healthStatus: groupStatus, healthyCount: resourceHealthyCount, @@ -217,6 +229,7 @@ const createGroupResourceNode = ( badgeColor: RESOURCE_COLORS.get(RESOURCE_BADGE_COLORS.get('.co-m-resource-' + kind.toLowerCase())) || RESOURCE_COLORS.get('color-container-dark'), + badgeTextColor: 'white', icon: kind, resourceChildrenIds: [], }, @@ -239,6 +252,27 @@ const createGroupResourceNode = ( return groupResourceNodeMap; }; +export const getResourceMapKey = (resource: ApplicationResourceStatus): string => { + return `${resource.group}-${resource.version}-${resource.kind}-${resource.namespace}-${resource.name}`; +}; + +export const getResourcePathForResource = ( + resource: ApplicationResourceStatus, + allK8sModels: { [key: string]: K8sModel }, +): string => { + if (resource.kind === ApplicationModel.kind) { + return ( + resourcePathFromModel(ApplicationModel as K8sModel, resource.name, resource.namespace) + + '/resources' + ); + } + const k8sModel = allK8sModels[resource.kind]; + if (!k8sModel) { + return ''; + } + return resourcePathFromModel(k8sModel, resource.name, resource.namespace); +}; + // Application Graph Nodes export const getInitialNodes = ( application: ApplicationKind, @@ -246,6 +280,8 @@ export const getInitialNodes = ( allK8sModels: { [key: string]: K8sModel }, showGroupNodes: boolean, groupNodeStates: string[], + resourceNodeLayout: boolean, + resourcePaths: Map, ) => { // This contains all the nodes we want to add to the graph view const initialNodes: NodeModel[] = []; @@ -253,7 +289,7 @@ export const getInitialNodes = ( let groupResourceNodeMap = new Map(); // Step 1. Create the Application Node - initialNodes.push(createApplicationNode(application)); + initialNodes.push(createApplicationNode(application, resourceNodeLayout)); // Step 2: Proceed with adding more nodes only if the application has resources // If we use the resource tree in the future, this will change @@ -261,17 +297,19 @@ export const getInitialNodes = ( // Spacer node to the right of the application node. Fixed. initialNodes.push(createSpacerNode(1, 'application-node-spacer')); // Add child resources - resources.forEach((resource) => { + resources.forEach((resource, count) => { const kind = resource.kind; const badgeLabel = allK8sModels[kind]?.abbr || kindToAbbr(kind); const color = RESOURCE_COLORS.get( RESOURCE_BADGE_COLORS.get('.co-m-resource-' + resource.kind.toLowerCase()), ) || RESOURCE_COLORS.get('color-container-dark'); - const nodeId = resource.kind + '-' + resource.name + '-' + resource.namespace; + const nodeId = count + '-' + resource.kind + '-' + resource.name + '-' + resource.namespace; const key = resource.kind + 's'; const resourceGroupExpandState = groupNodeStates.includes(key); + const resourcePath = resourcePaths.get(getResourceMapKey(resource)); + if (showGroupNodes && resources.filter((res) => res.kind === resource.kind).length > 1) { groupResourceNodeMap = createGroupResourceNode( kind, @@ -280,6 +318,7 @@ export const getInitialNodes = ( resources, allK8sModels, resourceGroupExpandState, + resourceNodeLayout, ); if (!initialNodes.includes(groupResourceNodeMap.get(kind))) { @@ -295,7 +334,7 @@ export const getInitialNodes = ( initialNodes.push({ id: nodeId, type: 'node', - label: resource.kind, + label: resourceNodeLayout ? ' ' : resource.kind, width: 280, height: NODE_DIAMETER, labelPosition: LabelPosition.bottom, @@ -305,6 +344,8 @@ export const getInitialNodes = ( name: resource.name, group: resource.group, kind: resource.kind, + resourceNodeLayout: resourceNodeLayout, + resourcePath: resourcePath, version: resource.version, namespace: resource.namespace, indent: 100, @@ -313,6 +354,7 @@ export const getInitialNodes = ( syncStatus: resource.status, rank: 5, badgeColor: color, + badgeTextColor: 'white', badge: badgeLabel, icon: kind, }, @@ -348,7 +390,6 @@ export const getInitialNodes = ( selectable: false, hideContextMenuKebab: true, hulledOutline: false, - style: { padding: 40 }, data: { kind: groupNode.data.kind, }, @@ -382,7 +423,6 @@ export const getInitialNodes = ( selectable: false, hideContextMenuKebab: true, hulledOutline: false, - style: { padding: 40 }, }; initialNodes.push(transparentGroupsOfGroups); } @@ -420,7 +460,7 @@ export const getInitialEdges = ( target: node.id, edgeStyle: EdgeStyle.dotted, data: { - indent: 100, + indent: 0, }, }); } @@ -432,7 +472,7 @@ export const getInitialEdges = ( target: node.data.kind + '-node-spacer', edgeStyle: EdgeStyle.dotted, data: { - indent: 100, + indent: 0, }, }); } @@ -445,17 +485,18 @@ export const getInitialEdges = ( if (node.type === 'node') { const b = nodes.filter( - (res) => res.type === 'node-group' && res.id === node.label + '-node-group', + (res) => res.type === 'node-group' && res.id === node.data.kind + '-node-group', ).length > 0; initialEdges.push({ - id: 'e-' + node.label + '-' + index, + id: 'e-' + node.data.kind + '-' + index, type: 'edge', nodeSeparation: 0, - source: showGroupNodes && b ? node.label + '-node-spacer' : 'application-node-spacer', + source: + showGroupNodes && b ? node.data.kind + '-node-spacer' : 'application-node-spacer', target: node.id, edgeStyle: EdgeStyle.default, data: { - indent: 100, + indent: 0, }, }); } diff --git a/src/gitops/components/application/graph/icons/resource-icons.tsx b/src/gitops/components/application/graph/icons/resource-icons.tsx index a3b169686..c16d56e2f 100644 --- a/src/gitops/components/application/graph/icons/resource-icons.tsx +++ b/src/gitops/components/application/graph/icons/resource-icons.tsx @@ -31,12 +31,17 @@ import { interface ResourceIconProps { kind: string; badge: string; + badgeIconTransform?: string; } const iconHeight = 21; const iconWidth = 21; -export const ResourceSvgIcon: React.FC = ({ kind, badge }) => { +export const ResourceSvgIcon: React.FC = ({ + kind, + badge, + badgeIconTransform, +}) => { let targetIcon: React.ReactNode; switch (kind) { case 'Namespace': @@ -77,14 +82,10 @@ export const ResourceSvgIcon: React.FC = ({ kind, badge }) => break; default: targetIcon = ( - - + + + + {badge} diff --git a/src/gitops/components/application/graph/nodes/ApplicationNode.tsx b/src/gitops/components/application/graph/nodes/ApplicationNode.tsx index 1e795f7d3..2180cbf76 100644 --- a/src/gitops/components/application/graph/nodes/ApplicationNode.tsx +++ b/src/gitops/components/application/graph/nodes/ApplicationNode.tsx @@ -19,7 +19,10 @@ import { PauseIcon, } from '@patternfly/react-icons'; import { + BadgeLocation, DefaultNode, + LabelBadge, + LabelPosition, Node, RectAnchor, ShapeProps, @@ -122,8 +125,8 @@ const ApplicationSyncStatusIcon = ({ status }: { status: SyncStatus }) => { ); }; -const ApplicationShape: React.FunctionComponent = observer( - ({ element, className, width, height, filter, dndDropRef }) => { +const ApplicationShape: React.FunctionComponent = + observer(({ element, className, width, height, filter, dndDropRef, resourceNodeLayout }) => { useAnchor(RectAnchor); const data = element.getData(); const anchorRef = useSvgAnchor(); @@ -148,7 +151,7 @@ const ApplicationShape: React.FunctionComponent = observer( = observer( ); - }, -); + }); export const ApplicationNode: React.FC< CustomNodeProps & WithSelectionProps & WithContextMenuProps > = observer(({ element, onContextMenu, contextMenuOpen, onSelect, selected }) => { const data = element.getData(); + const resourceNodeLayout = data.resourceNodeLayout as boolean; + return ( { - return ApplicationShape; + return (props: ShapeProps) => ( + + ); }} - /> + > + {resourceNodeLayout && ( + <> + + + + + )} + ); }); diff --git a/src/gitops/components/application/graph/nodes/ResourceGroupNode.tsx b/src/gitops/components/application/graph/nodes/ResourceGroupNode.tsx index d8e89f3f1..2e1e0279e 100644 --- a/src/gitops/components/application/graph/nodes/ResourceGroupNode.tsx +++ b/src/gitops/components/application/graph/nodes/ResourceGroupNode.tsx @@ -13,7 +13,10 @@ import { QuestionCircleIcon, } from '@patternfly/react-icons'; import { + BadgeLocation, DefaultNode, + LabelBadge, + LabelPosition, Node as TopologyNode, WithContextMenuProps, WithSelectionProps, @@ -31,6 +34,15 @@ export const ResourceGroupNode: React.FC< const data = element.getData(); // const Icon = data.icon ? data.icon : null; const kind = data.icon as string; + const resourceNodeLayout = data.resourceNodeLayout as boolean; + const truncatedBadge = data.badge.length > 3 ? data.badge.slice(0, 3) : data.badge; + let dx = 8; + if (truncatedBadge.length === 1) { + dx = 17; + } else if (truncatedBadge.length === 2) { + dx = 12; + } + const transform = resourceNodeLayout ? `scale(0.6) translate(8, 6)` : ''; return ( {kind !== null ? ( - - + + ) : ( @@ -71,6 +91,22 @@ export const ResourceGroupNode: React.FC< )} + {resourceNodeLayout && ( + <> + + + + + )} + = observer(({ element, onContextMenu, contextMenuOpen, onSelect, selected }) => { const data = element.getData(); - const kind = data.icon as string; - const location = useLocation(); + const kind = data.kind as string; + const resourceNodeLayout = data.resourceNodeLayout as boolean; + const truncatedBadge = data.badge.length > 3 ? data.badge.slice(0, 3) : data.badge; + let dx = 8; + if (truncatedBadge.length === 1) { + dx = 17; + } else if (truncatedBadge.length === 2) { + dx = 12; + } + const [k8sModel] = useK8sModel({ + group: data.group, + version: data.version, + kind: data.kind, + }); + const resourcePath = + data.kind === ApplicationModel.kind + ? resourcePathFromModel(ApplicationModel as K8sModel, data.name, data.namespace) + + '/resources' + : resourcePathFromModel(k8sModel as K8sModel, data.name, data.namespace); + const transform = resourceNodeLayout ? `scale(0.6) translate(8, 6)` : ''; return ( - + - + - + {resourceNodeLayout ? ( + <> + + + + + + ) : ( + + )} @@ -72,42 +116,39 @@ export const ResourceNode: React.FC )} - {kind === ApplicationModel.kind && ( + {data.resourceHealthStatus !== HealthStatus.MISSING && ( - { - e.stopPropagation(); - }} - title={t('Go to application')} + - + )} - {data.step && ( - - - {data.step !== '-1' - ? t('Step {{x}}', { - x: data.step, - }) - : t('Step: unmatched')} - - + {kind === ApplicationModel.kind && ( + <> + {data.step && ( + + + {data.step !== '-1' + ? t('Step {{x}}', { + x: data.step, + }) + : t('Step: unmatched')} + + + )} + )} ); diff --git a/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx b/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx index 559fff891..c4c0ad203 100644 --- a/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx +++ b/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx @@ -20,7 +20,13 @@ import { useLabelsModal, useUserSettings, } from '@openshift-console/dynamic-plugin-sdk'; -import { EllipsisHIcon, ObjectGroupIcon, SitemapIcon } from '@patternfly/react-icons'; +import { + EllipsisHIcon, + ObjectGroupIcon, + SitemapIcon, + ToggleOffIcon, + ToggleOnIcon, +} from '@patternfly/react-icons'; import { css } from '@patternfly/react-styles'; import { action, @@ -213,6 +219,9 @@ const AppSetContextMenuItem: React.FC = ({ case t('Delete ApplicationSet'): launchDeleteModal(); break; + case t('View Details'): + navigate(graphElement.getData().resourcePath); + break; } }; @@ -265,9 +274,9 @@ const getResourceMenuItems = ( return createContextMenuItems( graphElement, paramsRef, - t('Edit Application'), t('Delete Application'), '-', + t('View Details'), t('View in Argo CD'), ); } @@ -338,9 +347,19 @@ export const ApplicationSetGraphView: React.FC<{ : TreeViewLayout.OWNER_REFERENCE_LAYOUT, false, ); + const [resourceNodeLayout, setResourceNodeLayout] = useUserSettings( + 'redhat.gitops.resourceNodeLayout', + true, + false, + ); + // Use a setting to save the expand group state instead of alway having it set to a default value + const [expandGroups, setExpandGroups] = useUserSettings( + 'redhat.gitops.expandGroups', + false, + false, + ); // Track expanded step-groups - only expanded step-groups have their app nodes included in initialNodes const [expandedStepGroups, setExpandedStepGroups] = React.useState>(new Set()); - const [expandGroups, setExpandGroups] = React.useState(false); const [selectedIds, setSelectedIds] = React.useState([]); const [renderKey, setRenderKey] = React.useState(0); // Track if initial collapse is pending - hide graph until complete to avoid flicker @@ -393,6 +412,7 @@ export const ApplicationSetGraphView: React.FC<{ allK8sModels, adjustedExpansionSetting, expandedStepGroups, + resourceNodeLayout, ); const initialEdges = getInitialEdges( applicationSet, @@ -540,7 +560,6 @@ export const ApplicationSetGraphView: React.FC<{ } | null>(null); // Track if initial collapse has been done const initialCollapseAppliedRef = React.useRef(false); - // Ref to always access current controller in setTimeout const controllerRef = React.useRef(controller); controllerRef.current = controller; const currentNodeCount = nodes.length; @@ -550,9 +569,13 @@ export const ApplicationSetGraphView: React.FC<{ .sort() .join(','); const previousNodeIds = previousNodeIdsRef.current; + const resourceNodeLayoutRef = React.useRef(resourceNodeLayout); + const previousResourceNodeLayout = resourceNodeLayoutRef.current; const isStructuralChange = - currentNodeCount !== previousNodeCount || currentNodeIds !== previousNodeIds; + currentNodeCount !== previousNodeCount || + currentNodeIds !== previousNodeIds || + previousResourceNodeLayout != resourceNodeLayout; if (isStructuralChange || previousNodeCount === 0) { // Save graph scale and position before updating model @@ -621,6 +644,10 @@ export const ApplicationSetGraphView: React.FC<{ } graph2.layout(); }); + } else { + if (expandGroups) { + setExpandedStepGroups(new Set(stepGroupIds)); + } } // Restore graph scale and position after model update @@ -641,6 +668,7 @@ export const ApplicationSetGraphView: React.FC<{ previousNodeCountRef.current = currentNodeCount; previousNodeIdsRef.current = currentNodeIds; + resourceNodeLayoutRef.current = resourceNodeLayout; } else { // Data change only: Update ONLY changed nodes (no layout, no position changes) let updateCount = 0; @@ -789,6 +817,22 @@ export const ApplicationSetGraphView: React.FC<{ controller.getGraph().layout(); }), customButtons: [ + { + id: 'toggle-node-layout', + icon: resourceNodeLayout ? : , + tooltip: t( + 'Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}', + { x: resourceNodeLayout ? 'Argo CD' : 'OpenShift' }, + ), + ariaLabel: t( + 'Toggle between OpenShift shapes and Argo CD shapes for tree nodes. Current setting: {{x}}', + { x: resourceNodeLayout ? 'Argo CD' : 'OpenShift' }, + ), + callback: () => { + setResourceNodeLayout(!resourceNodeLayout); + controller.getGraph().layout(); + }, + }, { id: 'setting-owner-reference-layout', icon: , @@ -868,11 +912,7 @@ export const ApplicationSetGraphView: React.FC<{ skipExpandGroupsEffectRef.current = true; // Update expandGroups state - if (expandGroups) { - setExpandGroups(false); - } else { - setExpandGroups(true); - } + setExpandGroups(!expandGroups); }, }, ], diff --git a/src/gitops/components/appset/graph/graph-utils.tsx b/src/gitops/components/appset/graph/graph-utils.tsx index d899bad41..4447c0400 100644 --- a/src/gitops/components/appset/graph/graph-utils.tsx +++ b/src/gitops/components/appset/graph/graph-utils.tsx @@ -10,6 +10,7 @@ import { import { ApplicationKind, ApplicationModel } from '@gitops/models/ApplicationModel'; import { ApplicationSetKind, ApplicationStatusContent } from '@gitops/models/ApplicationSetModel'; import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { resourcePathFromModel } from '@gitops/utils/utils'; import { K8sModel } from '@openshift-console/dynamic-plugin-sdk'; import { CenterAnchor, @@ -31,7 +32,10 @@ const NODE_TYPE_APPLICATIONSET = 'applicationset-node'; const NODE_TYPE_APPLICATIONSET_LABEL = 'ApplicationSet'; const STEP_GROUP_WIDTH = 300; -const createApplicationSetNode = (applicationSet: ApplicationSetKind): NodeModel => { +const createApplicationSetNode = ( + applicationSet: ApplicationSetKind, + resourceNodeLayout: boolean, +): NodeModel => { const appSetHealthStatus = getAppSetHealthStatus(applicationSet); const nodeStatus = getTopologyNodeStatus(getAppSetHealthStatus(applicationSet)); return { @@ -42,17 +46,19 @@ const createApplicationSetNode = (applicationSet: ApplicationSetKind): NodeModel '-' + applicationSet?.metadata?.namespace, type: NODE_TYPE_APPLICATIONSET, - label: NODE_TYPE_APPLICATIONSET_LABEL, + label: resourceNodeLayout ? ' ' : NODE_TYPE_APPLICATIONSET_LABEL, status: nodeStatus, width: APP_NODE_WIDTH, height: APP_NODE_HEIGHT, data: { name: applicationSet?.metadata?.name, kind: applicationSet?.kind, + resourceNodeLayout: resourceNodeLayout, badge: 'AS', badgeColor: RESOURCE_COLORS.get( RESOURCE_BADGE_COLORS.get('.co-m-resource-' + applicationSet?.kind.toLowerCase()), ), + badgeTextColor: 'white', badgeBorderColor: RESOURCE_COLORS.get( RESOURCE_BADGE_COLORS.get('.co-m-resource-' + applicationSet?.kind.toLowerCase()), ), @@ -72,11 +78,13 @@ const createApplicationNode = ( color: string, badgeLabel: string, kind: string, + resourceNodeLayout: boolean, + resourcePath: string, ): NodeModel => { return { id: nodeId, type: 'node', - label: application.kind, + label: resourceNodeLayout ? ' ' : application.kind, width: 280, height: NODE_DIAMETER, labelPosition: LabelPosition.bottom, @@ -86,6 +94,8 @@ const createApplicationNode = ( name: application.metadata?.name, id: nodeId, step: appResource?.step || undefined, + resourceNodeLayout: resourceNodeLayout, + resourcePath: resourcePath, resourcesLength: application?.status?.resources?.length || 0, group: ApplicationModel.apiGroup || 'argoproj.io', appIndex: appIndex, @@ -96,6 +106,7 @@ const createApplicationNode = ( resourceHealthStatus: application.status?.health?.status || undefined, syncStatus: application.status?.sync?.status, badgeColor: color, + badgeTextColor: 'white', badge: badgeLabel, icon: kind, }, @@ -109,6 +120,7 @@ export const getInitialNodes = ( allK8sModels: { [key: string]: K8sModel }, isOwnerReferenceView: boolean, // OwnerReference view or Progressive Sync flow view expandedStepGroups: Set = new Set(), + resourceNodeLayout: boolean, ) => { // This contains all the nodes we want to add to the graph view const initialNodes: NodeModel[] = []; @@ -124,7 +136,7 @@ export const getInitialNodes = ( } } // Step 1. Create the ApplicationSet Node - initialNodes.push(createApplicationSetNode(applicationSet)); + initialNodes.push(createApplicationSetNode(applicationSet, resourceNodeLayout)); // Step 2: Proceed with adding apps @@ -136,6 +148,13 @@ export const getInitialNodes = ( // Add applications to nodes list applications.forEach((application, appIndex) => { const kind = application.kind; + const resourcePath = + resourcePathFromModel( + ApplicationModel as K8sModel, + application.metadata?.name, + application.metadata?.namespace, + ) + '/resources'; + const badgeLabel = allK8sModels[kind]?.abbr || kindToAbbr(kind); const color = RESOURCE_COLORS.get( @@ -183,6 +202,8 @@ export const getInitialNodes = ( color, badgeLabel, kind, + resourceNodeLayout, + resourcePath, ), ); } else if (isOwnerRefOnly) { @@ -196,6 +217,8 @@ export const getInitialNodes = ( color, badgeLabel, kind, + resourceNodeLayout, + resourcePath, ), ); } @@ -313,7 +336,7 @@ export const getInitialNodes = ( }; export const getInitialEdges = ( - applicationSet: ApplicationSetKind, + application: ApplicationSetKind, applications: ApplicationKind[], nodes: NodeModel[], isOwnerReferenceView: boolean, @@ -334,11 +357,11 @@ export const getInitialEdges = ( id: 'e-applicationset', type: isOwnerReferenceView ? 'edge' : 'task-edge', source: - applicationSet.kind + + application.kind + '-' + - (applicationSet.metadata?.name ?? '') + + (application.metadata?.name ?? '') + '-' + - applicationSet.metadata?.namespace, + application.metadata?.namespace, target: isOwnerReferenceView ? 'applicationset-node-spacer' : '1-step-group', nodeSeparation: 0, edgeStyle: EdgeStyle.default, diff --git a/src/gitops/components/appset/graph/nodes/ApplicationSetNode.tsx b/src/gitops/components/appset/graph/nodes/ApplicationSetNode.tsx index 327ccf18f..a79d3110d 100644 --- a/src/gitops/components/appset/graph/nodes/ApplicationSetNode.tsx +++ b/src/gitops/components/appset/graph/nodes/ApplicationSetNode.tsx @@ -14,7 +14,10 @@ import { PauseIcon, } from '@patternfly/react-icons'; import { + BadgeLocation, DefaultNode, + LabelBadge, + LabelPosition, Node, RectAnchor, ShapeProps, @@ -183,11 +186,20 @@ export const ApplicationSetNode: React.FC< CustomNodeProps & WithSelectionProps & WithContextMenuProps > = observer(({ element, onContextMenu, contextMenuOpen, onSelect, selected }) => { const data = element.getData(); + const resourceNodeLayout = data.resourceNodeLayout as boolean; return ( { return ApplicationSetShape; }} - /> + > + {resourceNodeLayout && ( + <> + + + + + )} + ); }); diff --git a/src/gitops/components/graph/SvgTextWithOverflow.tsx b/src/gitops/components/graph/SvgTextWithOverflow.tsx index d5c58c218..f5ee80d4a 100644 --- a/src/gitops/components/graph/SvgTextWithOverflow.tsx +++ b/src/gitops/components/graph/SvgTextWithOverflow.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; const SvgTextWithOverflow = ({ text, maxWidth, x, y }) => { + const originalText = text; const textRef = React.useRef(null); const [displayedText, setDisplayedText] = React.useState(text); @@ -27,9 +28,12 @@ const SvgTextWithOverflow = ({ text, maxWidth, x, y }) => { }, [text, maxWidth]); return ( - - {displayedText} - + + {originalText} + + {displayedText} + + ); }; diff --git a/src/gitops/utils/utils.tsx b/src/gitops/utils/utils.tsx index ee7a0e43e..2f2898ec3 100644 --- a/src/gitops/utils/utils.tsx +++ b/src/gitops/utils/utils.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { COLORS } from '@gitops/components/shared/colors'; import { + getGroupVersionKindForModel, GroupVersionKind, K8sResourceCommon, K8sResourceKindReference, @@ -50,6 +51,37 @@ export const getResourceUrl = (urlProps: ResourceUrlProps): string => { return `/k8s/${namespaced ? namespaceUrl : 'cluster'}/${ref}/${name}`; }; +export const resourcePathFromModel = (model: K8sModel, name?: string, namespace?: string) => { + const { plural, namespaced, crd } = model; + + let url = '/k8s/'; + + if (!namespaced) { + url += 'cluster/'; + } + + if (namespaced) { + url += namespace ? `ns/${namespace}/` : 'all-namespaces/'; + } + // 'argoproj.io~v1alpha1~Application' + if (crd) { + url += + model.kind === 'Application' + ? 'argoproj.io~v1alpha1~Application' + : getGroupVersionKindForModel(model); + } else if (plural) { + url += plural; + } + + if (name) { + // Some resources have a name that needs to be encoded. For instance, + // Users can have special characters in the name like `#`. + url += `/${encodeURIComponent(name)}`; + } + + return url; +}; + export function useObjectModifyPermissions( obj: K8sResourceCommon, model: K8sModel,