From be8575afae02af1bfc543a4462003039206c568a Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Tue, 14 Apr 2026 01:16:46 -0400 Subject: [PATCH 1/2] Change resource node shape to be classic PatternFly vs Argo CD (#9329) Signed-off-by: Keith Chong --- locales/en/plugin__gitops-plugin.json | 1 + locales/ja/plugin__gitops-plugin.json | 1 + locales/ko/plugin__gitops-plugin.json | 1 + locales/zh/plugin__gitops-plugin.json | 1 + .../graph/ApplicationGraphView.scss | 50 +++++++- .../graph/ApplicationGraphView.tsx | 33 ++++- .../application/graph/graph-utils.tsx | 41 ++++--- .../graph/icons/resource-icons.tsx | 19 +-- .../graph/nodes/ApplicationNode.tsx | 44 +++++-- .../graph/nodes/ResourceGroupNode.tsx | 40 ++++++- .../application/graph/nodes/ResourceNode.tsx | 113 ++++++++++++------ .../appset/graph/ApplicationSetGraphView.tsx | 37 +++++- .../components/appset/graph/graph-utils.tsx | 27 +++-- .../appset/graph/nodes/ApplicationSetNode.tsx | 30 ++++- .../components/graph/SvgTextWithOverflow.tsx | 10 +- 15 files changed, 357 insertions(+), 91 deletions(-) diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index 2e1090283..3283d7f15 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -120,6 +120,7 @@ "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", diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index ba4a09539..c96279221 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -120,6 +120,7 @@ "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", diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index b9a275972..dfd85c6bb 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -120,6 +120,7 @@ "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", diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index 1d8b344a1..33572501f 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -120,6 +120,7 @@ "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", 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..deb41861e 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, @@ -66,7 +66,7 @@ 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', @@ -308,6 +308,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(() => { @@ -363,6 +368,7 @@ export const ApplicationGraphView: React.FC<{ allK8sModels, groupNodeState, groupNodeStates, + resourceNodeLayout, ); const initialEdges = getInitialEdges(application, initialNodes, groupNodeState); const nodes = [...initialNodes]; @@ -373,12 +379,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 +405,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 +473,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..f70acc7d3 100644 --- a/src/gitops/components/application/graph/graph-utils.tsx +++ b/src/gitops/components/application/graph/graph-utils.tsx @@ -24,7 +24,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 +37,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 +50,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 +110,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 +192,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 +202,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 +224,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: [], }, @@ -246,6 +254,7 @@ export const getInitialNodes = ( allK8sModels: { [key: string]: K8sModel }, showGroupNodes: boolean, groupNodeStates: string[], + resourceNodeLayout: boolean, ) => { // This contains all the nodes we want to add to the graph view const initialNodes: NodeModel[] = []; @@ -253,7 +262,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,14 +270,14 @@ 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); @@ -280,6 +289,7 @@ export const getInitialNodes = ( resources, allK8sModels, resourceGroupExpandState, + resourceNodeLayout, ); if (!initialNodes.includes(groupResourceNodeMap.get(kind))) { @@ -295,7 +305,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 +315,7 @@ export const getInitialNodes = ( name: resource.name, group: resource.group, kind: resource.kind, + resourceNodeLayout: resourceNodeLayout, version: resource.version, namespace: resource.namespace, indent: 100, @@ -313,6 +324,7 @@ export const getInitialNodes = ( syncStatus: resource.status, rank: 5, badgeColor: color, + badgeTextColor: 'white', badge: badgeLabel, icon: kind, }, @@ -348,7 +360,6 @@ export const getInitialNodes = ( selectable: false, hideContextMenuKebab: true, hulledOutline: false, - style: { padding: 40 }, data: { kind: groupNode.data.kind, }, @@ -382,7 +393,6 @@ export const getInitialNodes = ( selectable: false, hideContextMenuKebab: true, hulledOutline: false, - style: { padding: 40 }, }; initialNodes.push(transparentGroupsOfGroups); } @@ -420,7 +430,7 @@ export const getInitialEdges = ( target: node.id, edgeStyle: EdgeStyle.dotted, data: { - indent: 100, + indent: 0, }, }); } @@ -432,7 +442,7 @@ export const getInitialEdges = ( target: node.data.kind + '-node-spacer', edgeStyle: EdgeStyle.dotted, data: { - indent: 100, + indent: 0, }, }); } @@ -445,17 +455,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 && ( + <> + + + + + )} + { const data = element.getData(); const kind = data.icon as string; + const resourceNodeLayout = data.resourceNodeLayout as boolean; const location = useLocation(); + 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 ( - + - + - + {resourceNodeLayout ? ( + <> + + + + + + ) : ( + + )} @@ -73,41 +106,43 @@ export const ResourceNode: React.FC {kind === ApplicationModel.kind && ( - - { - e.stopPropagation(); - }} - title={t('Go to application')} + <> + - - - - )} - {data.step && ( - - - {data.step !== '-1' - ? t('Step {{x}}', { - x: data.step, - }) - : t('Step: unmatched')} - - + { + e.stopPropagation(); + }} + title={t('Go to application')} + > + + + + {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..f66b7f0a2 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, @@ -338,6 +344,11 @@ export const ApplicationSetGraphView: React.FC<{ : TreeViewLayout.OWNER_REFERENCE_LAYOUT, false, ); + const [resourceNodeLayout, setResourceNodeLayout] = useUserSettings( + 'redhat.gitops.resourceNodeLayout', + true, + 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); @@ -393,6 +404,7 @@ export const ApplicationSetGraphView: React.FC<{ allK8sModels, adjustedExpansionSetting, expandedStepGroups, + resourceNodeLayout, ); const initialEdges = getInitialEdges( applicationSet, @@ -550,9 +562,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 @@ -641,6 +657,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 +806,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: , diff --git a/src/gitops/components/appset/graph/graph-utils.tsx b/src/gitops/components/appset/graph/graph-utils.tsx index d899bad41..86cfdd355 100644 --- a/src/gitops/components/appset/graph/graph-utils.tsx +++ b/src/gitops/components/appset/graph/graph-utils.tsx @@ -31,7 +31,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 +45,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 +77,12 @@ const createApplicationNode = ( color: string, badgeLabel: string, kind: string, + resourceNodeLayout: boolean, ): NodeModel => { return { id: nodeId, type: 'node', - label: application.kind, + label: resourceNodeLayout ? ' ' : application.kind, width: 280, height: NODE_DIAMETER, labelPosition: LabelPosition.bottom, @@ -86,6 +92,7 @@ const createApplicationNode = ( name: application.metadata?.name, id: nodeId, step: appResource?.step || undefined, + resourceNodeLayout: resourceNodeLayout, resourcesLength: application?.status?.resources?.length || 0, group: ApplicationModel.apiGroup || 'argoproj.io', appIndex: appIndex, @@ -96,6 +103,7 @@ const createApplicationNode = ( resourceHealthStatus: application.status?.health?.status || undefined, syncStatus: application.status?.sync?.status, badgeColor: color, + badgeTextColor: 'white', badge: badgeLabel, icon: kind, }, @@ -109,6 +117,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 +133,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 @@ -183,6 +192,7 @@ export const getInitialNodes = ( color, badgeLabel, kind, + resourceNodeLayout, ), ); } else if (isOwnerRefOnly) { @@ -196,6 +206,7 @@ export const getInitialNodes = ( color, badgeLabel, kind, + resourceNodeLayout, ), ); } @@ -313,7 +324,7 @@ export const getInitialNodes = ( }; export const getInitialEdges = ( - applicationSet: ApplicationSetKind, + application: ApplicationSetKind, applications: ApplicationKind[], nodes: NodeModel[], isOwnerReferenceView: boolean, @@ -334,11 +345,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} + + ); }; From 5c1d5e71d0f89116c0991938686c1ffdd0b7ac80 Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Mon, 20 Apr 2026 16:35:12 -0400 Subject: [PATCH 2/2] Link to details page, Remove Edit actions (#9329) Signed-off-by: Keith Chong --- locales/en/plugin__gitops-plugin.json | 2 +- locales/ja/plugin__gitops-plugin.json | 2 +- locales/ko/plugin__gitops-plugin.json | 2 +- locales/zh/plugin__gitops-plugin.json | 2 +- .../graph/ApplicationGraphView.tsx | 40 ++++++++----- .../application/graph/graph-utils.tsx | 32 +++++++++- .../application/graph/nodes/ResourceNode.tsx | 60 ++++++++++--------- .../appset/graph/ApplicationSetGraphView.tsx | 23 ++++--- .../components/appset/graph/graph-utils.tsx | 12 ++++ src/gitops/utils/utils.tsx | 32 ++++++++++ 10 files changed, 151 insertions(+), 56 deletions(-) diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index 3283d7f15..bf43198f5 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -116,6 +116,7 @@ "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}}", @@ -126,7 +127,6 @@ "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 c96279221..be8e4aee2 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -116,6 +116,7 @@ "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}}", @@ -126,7 +127,6 @@ "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 dfd85c6bb..bc0731941 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -116,6 +116,7 @@ "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}}", @@ -126,7 +127,6 @@ "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 33572501f..d5211346c 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -116,6 +116,7 @@ "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}}", @@ -126,7 +127,6 @@ "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.tsx b/src/gitops/components/application/graph/ApplicationGraphView.tsx index deb41861e..7cc6bef0b 100644 --- a/src/gitops/components/application/graph/ApplicationGraphView.tsx +++ b/src/gitops/components/application/graph/ApplicationGraphView.tsx @@ -59,7 +59,12 @@ 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'; @@ -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'), ]); } @@ -362,6 +357,18 @@ 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, @@ -369,6 +376,7 @@ export const ApplicationGraphView: React.FC<{ groupNodeState, groupNodeStates, resourceNodeLayout, + resourcePaths, ); const initialEdges = getInitialEdges(application, initialNodes, groupNodeState); const nodes = [...initialNodes]; diff --git a/src/gitops/components/application/graph/graph-utils.tsx b/src/gitops/components/application/graph/graph-utils.tsx index f70acc7d3..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, @@ -247,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, @@ -255,6 +281,7 @@ export const getInitialNodes = ( showGroupNodes: boolean, groupNodeStates: string[], resourceNodeLayout: boolean, + resourcePaths: Map, ) => { // This contains all the nodes we want to add to the graph view const initialNodes: NodeModel[] = []; @@ -281,6 +308,8 @@ export const getInitialNodes = ( 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, @@ -316,6 +345,7 @@ export const getInitialNodes = ( group: resource.group, kind: resource.kind, resourceNodeLayout: resourceNodeLayout, + resourcePath: resourcePath, version: resource.version, namespace: resource.namespace, indent: 100, diff --git a/src/gitops/components/application/graph/nodes/ResourceNode.tsx b/src/gitops/components/application/graph/nodes/ResourceNode.tsx index e11827e37..c240603fb 100644 --- a/src/gitops/components/application/graph/nodes/ResourceNode.tsx +++ b/src/gitops/components/application/graph/nodes/ResourceNode.tsx @@ -2,13 +2,15 @@ * Resource Node for Argo CD Resources */ import * as React from 'react'; -import { useLocation } from 'react-router-dom-v5-compat'; +import { Link } from 'react-router-dom-v5-compat'; import { observer } from 'mobx-react'; import SvgTextWithOverflow from '@gitops/components/graph/SvgTextWithOverflow'; import { ApplicationModel } from '@gitops/models/ApplicationModel'; import { HealthStatus } from '@gitops/utils/constants'; import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { resourcePathFromModel } from '@gitops/utils/utils'; +import { K8sModel, useK8sModel } from '@openshift-console/dynamic-plugin-sdk'; import { BadgeLocation, DefaultNode, @@ -33,9 +35,8 @@ interface CustomNodeProps { export const ResourceNode: React.FC = observer(({ element, onContextMenu, contextMenuOpen, onSelect, selected }) => { const data = element.getData(); - const kind = data.icon as string; + const kind = data.kind as string; const resourceNodeLayout = data.resourceNodeLayout as boolean; - const location = useLocation(); const truncatedBadge = data.badge.length > 3 ? data.badge.slice(0, 3) : data.badge; let dx = 8; if (truncatedBadge.length === 1) { @@ -43,6 +44,16 @@ export const ResourceNode: React.FC )} + {data.resourceHealthStatus !== HealthStatus.MISSING && ( + + + + + + )} {kind === ApplicationModel.kind && ( <> - - { - e.stopPropagation(); - }} - title={t('Go to application')} - > - - - {data.step && ( diff --git a/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx b/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx index f66b7f0a2..c4c0ad203 100644 --- a/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx +++ b/src/gitops/components/appset/graph/ApplicationSetGraphView.tsx @@ -219,6 +219,9 @@ const AppSetContextMenuItem: React.FC = ({ case t('Delete ApplicationSet'): launchDeleteModal(); break; + case t('View Details'): + navigate(graphElement.getData().resourcePath); + break; } }; @@ -271,9 +274,9 @@ const getResourceMenuItems = ( return createContextMenuItems( graphElement, paramsRef, - t('Edit Application'), t('Delete Application'), '-', + t('View Details'), t('View in Argo CD'), ); } @@ -349,9 +352,14 @@ export const ApplicationSetGraphView: React.FC<{ 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 @@ -552,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; @@ -637,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 @@ -901,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 86cfdd355..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, @@ -78,6 +79,7 @@ const createApplicationNode = ( badgeLabel: string, kind: string, resourceNodeLayout: boolean, + resourcePath: string, ): NodeModel => { return { id: nodeId, @@ -93,6 +95,7 @@ const createApplicationNode = ( id: nodeId, step: appResource?.step || undefined, resourceNodeLayout: resourceNodeLayout, + resourcePath: resourcePath, resourcesLength: application?.status?.resources?.length || 0, group: ApplicationModel.apiGroup || 'argoproj.io', appIndex: appIndex, @@ -145,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( @@ -193,6 +203,7 @@ export const getInitialNodes = ( badgeLabel, kind, resourceNodeLayout, + resourcePath, ), ); } else if (isOwnerRefOnly) { @@ -207,6 +218,7 @@ export const getInitialNodes = ( badgeLabel, kind, resourceNodeLayout, + resourcePath, ), ); } 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,