From 9ffbe394f4dc412629ef4c9a0d2e1e4b8a973553 Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Fri, 6 May 2022 17:50:11 -0400 Subject: [PATCH] feat: Network view should group pods into higher level workload (#5468) Signed-off-by: Keith Chong --- .../application-details.tsx | 4 +- .../application-pod-view/pod-view.tsx | 38 +-- .../application-resource-tree.scss | 160 ++++++++++ .../application-resource-tree.tsx | 294 +++++++++++++++++- ui/src/app/applications/components/utils.tsx | 30 +- 5 files changed, 481 insertions(+), 45 deletions(-) diff --git a/ui/src/app/applications/components/application-details/application-details.tsx b/ui/src/app/applications/components/application-details/application-details.tsx index 5030c3749fdeb..5706ade6ed908 100644 --- a/ui/src/app/applications/components/application-details/application-details.tsx +++ b/ui/src/app/applications/components/application-details/application-details.tsx @@ -306,10 +306,10 @@ export class ApplicationDetails extends React.Component - {pref.view === 'tree' && ( + {(pref.view === 'tree' || pref.view === 'network') && ( this.toggleCompactView(pref)}> diff --git a/ui/src/app/applications/components/application-pod-view/pod-view.tsx b/ui/src/app/applications/components/application-pod-view/pod-view.tsx index d913aabceb27a..aaf0d02fd5d8c 100644 --- a/ui/src/app/applications/components/application-pod-view/pod-view.tsx +++ b/ui/src/app/applications/components/application-pod-view/pod-view.tsx @@ -1,17 +1,17 @@ -import {DataLoader, DropDown, DropDownMenu, MenuItem, NotificationType, Tooltip} from 'argo-ui'; +import {DataLoader, DropDown, DropDownMenu, MenuItem, Tooltip} from 'argo-ui'; import * as PropTypes from 'prop-types'; import * as React from 'react'; import Moment from 'react-moment'; import {AppContext} from '../../../shared/context'; -import {CheckboxField, EmptyState, ErrorNotification} from '../../../shared/components'; +import {EmptyState} from '../../../shared/components'; import {Application, ApplicationTree, HostResourceInfo, InfoItem, Node, Pod, ResourceName, ResourceNode, ResourceStatus} from '../../../shared/models'; import {PodViewPreferences, services, ViewPreferences} from '../../../shared/services'; import {ResourceTreeNode} from '../application-resource-tree/application-resource-tree'; import {ResourceIcon} from '../resource-icon'; import {ResourceLabel} from '../resource-label'; -import {ComparisonStatusIcon, isYoungerThanXMinutes, HealthStatusIcon, nodeKey, PodHealthIcon} from '../utils'; +import {ComparisonStatusIcon, isYoungerThanXMinutes, HealthStatusIcon, nodeKey, PodHealthIcon, deletePodAction} from '../utils'; import './pod-view.scss'; @@ -25,7 +25,7 @@ interface PodViewProps { export type PodGroupType = 'topLevelResource' | 'parentResource' | 'node'; -interface PodGroup extends Partial { +export interface PodGroup extends Partial { type: PodGroupType; pods: Pod[]; info?: InfoItem[]; @@ -45,34 +45,6 @@ export class PodView extends React.Component { apis: PropTypes.object }; - deleteAction = async (pod: Pod) => { - this.appContext.apis.popup.prompt( - 'Delete pod', - () => ( -
-

Are you sure you want to delete Pod '{pod.name}'?

-
- - -
-
- ), - { - submit: async (vals, _, close) => { - try { - await services.applications.deleteResource(this.props.app.metadata.name, pod, !!vals.force, false); - close(); - } catch (e) { - this.appContext.apis.notifications.show({ - content: , - type: NotificationType.Error - }); - } - } - } - ); - }; - public render() { return ( services.viewPreferences.getPreferences()}> @@ -263,7 +235,7 @@ export class PodView extends React.Component { ), action: () => { - this.deleteAction(pod); + deletePodAction(pod, this.appContext, this.props.app.metadata.name); } } ]} diff --git a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss index 206cc8fc81312..5ee04c3c8b84f 100644 --- a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss +++ b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss @@ -57,6 +57,13 @@ } } + $pod-size: 25px; + $gutter: 3px; + $pods-per-row: 8; + $pods-per-column: 4; + $max-rows: 5; + $num-stats: 2; + &__node { position: absolute; transition: all 0.2s linear; @@ -85,6 +92,159 @@ cursor: default; background-color: $argo-color-teal-2; } + + &--lower-section { + left: 8px; + right: 10px; + margin-top: 10px; + margin-bottom: 10px; + $pod-container-width: $pods-per-row * ($pod-size + (2 * $gutter)) + 4 * $gutter; + $pod-container-height: $pods-per-column * ($pod-size + (2 * $gutter)) + 4 * $gutter; + $padding: 1px; + $stat-width: 1px; + padding: $padding; + transition: all 1s linear; + position: absolute; + + &__pod-group { + $pod-container-width: $pods-per-row * ($pod-size + (2 * $gutter)) + 4 * $gutter; + $pod-container-height: $pods-per-column * ($pod-size + (2 * $gutter)) + 4 * $gutter; + padding: $padding; + width: $pod-container-width + 2 * $padding; + + &__label { + margin-top: 1em; + font-size: 10px; + text-align: center; + } + &__pod-container { + flex-direction: column; + width: $pod-container-width; + margin-top: auto; + &__pods { + display: flex; + flex-wrap: wrap; + width: 100%; + background-color: $argo-color-gray-3; + border-radius: 3px; + padding: $gutter * 2; + margin-right: -1 * $gutter; + margin-bottom: -1 * $gutter; + } + } + + &__pod { + border-radius: 3px; + width: $pod-size; + height: $pod-size; + margin: $gutter; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background-color: $argo-color-gray-5; + transition: all 0.2s ease-in-out; + i.fa { + color: white !important; + } + &--succeeded, + &--healthy { + background-color: $argo-success-color; + &:hover { + background-color: $argo-success-color-dark; + } + } + &--pending, + &--suspended { + background-color: $argo-status-warning-color; + &:hover { + background-color: darken($argo-status-warning-color, 10%); + } + } + &--running, + &--progressing { + background-color: $argo-running-color; + &:hover { + background-color: $argo-running-color-dark; + } + } + &--failed, + &--degraded { + background-color: $argo-failed-color; + border: 2px solid rgba(0, 0, 0, 0.3); + &:hover { + background-color: $argo-failed-color-dark; + } + } + &--unknown, + &--missing { + background-color: $argo-color-gray-5; + &:hover { + background-color: $argo-color-gray-6; + } + } + &__star-icon { + background: none; + color: #ffce25; + display: block; + left: 20px; + margin: 0px; + position: absolute; + top: -5px; + } + &__stat-tooltip { + text-align: left; + + i { + display: inline-block; + height: 1em; + width: 1em; + border-radius: 5px; + } + } + + &__stat-icon-app { + background-color: $argo-color-teal-7; + } + + &__stat-icon-neighbors { + background-color: $argo-color-gray-6; + } + + &__stat { + &__bar { + background-color: $argo-color-gray-4; + height: $max-rows * $pod-size; + width: $stat-width; + position: relative; + border-radius: 2px; + margin: 0 $gutter * 2; + overflow: hidden; + cursor: pointer; + + &--fill { + position: absolute; + background-color: $argo-color-teal-7; + width: 100%; + bottom: 0; + } + + &--neighbors { + background-color: $argo-color-gray-6; + } + + &:hover > &--fill { + background-color: $argo-color-teal-8; + } + + &:hover &--neighbors { + background-color: $argo-color-gray-7; + } + } + } + } + } + } } &__filtered-indicator { diff --git a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx index ef1ea4fa16e09..1e1ff0b667fe2 100644 --- a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx +++ b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx @@ -1,4 +1,4 @@ -import {DropDown, Tooltip} from 'argo-ui'; +import {DropDown, DropDownMenu, Tooltip} from 'argo-ui'; import * as classNames from 'classnames'; import * as dagre from 'dagre'; import * as React from 'react'; @@ -7,12 +7,25 @@ import Moment from 'react-moment'; import * as models from '../../../shared/models'; import {EmptyState} from '../../../shared/components'; -import {Consumer} from '../../../shared/context'; +import {AppContext, Consumer} from '../../../shared/context'; import {ApplicationURLs} from '../application-urls'; import {ResourceIcon} from '../resource-icon'; import {ResourceLabel} from '../resource-label'; -import {BASE_COLORS, ComparisonStatusIcon, getAppOverridesCount, getExternalUrls, HealthStatusIcon, isAppNode, NodeId, nodeKey} from '../utils'; +import { + BASE_COLORS, + ComparisonStatusIcon, + deletePodAction, + getAppOverridesCount, + getExternalUrls, + HealthStatusIcon, + isAppNode, + isYoungerThanXMinutes, + NodeId, + nodeKey, + PodHealthIcon +} from '../utils'; import {NodeUpdateAnimation} from './node-update-animation'; +import {PodGroup} from '../application-pod-view/pod-view'; function treeNodeKey(node: NodeId & {uid?: string}) { return node.uid || nodeKey(node); @@ -29,6 +42,7 @@ export interface ResourceTreeNode extends models.ResourceNode { root?: ResourceTreeNode; requiresPruning?: boolean; orphaned?: boolean; + podGroup?: PodGroup; } export interface ApplicationResourceTreeProps { @@ -41,6 +55,7 @@ export interface ApplicationResourceTreeProps { onGroupdNodeClick?: (groupedNodeIds: string[]) => any; nodeMenu?: (node: models.ResourceNode) => React.ReactNode; onClearFilter: () => any; + appContext?: AppContext; showOrphanedResources: boolean; showCompactNodes: boolean; zoom: number; @@ -58,6 +73,7 @@ interface Line { const NODE_WIDTH = 282; const NODE_HEIGHT = 52; +const POD_NODE_HEIGHT = 136; const FILTERED_INDICATOR_NODE = '__filtered_indicator__'; const EXTERNAL_TRAFFIC_NODE = '__external_traffic__'; const INTERNAL_TRAFFIC_NODE = '__internal_traffic__'; @@ -66,7 +82,8 @@ const NODE_TYPES = { externalTraffic: 'external_traffic', externalLoadBalancer: 'external_load_balancer', internalTraffic: 'internal_traffic', - groupedNodes: 'grouped_nodes' + groupedNodes: 'grouped_nodes', + podGroup: 'pod_group' }; // generate lots of colors with different darkness @@ -308,6 +325,249 @@ export const describeNode = (node: ResourceTreeNode) => { return lines.join('\n'); }; +function processPodGroup(targetPodGroup: ResourceTreeNode, child: ResourceTreeNode, props: ApplicationResourceTreeProps) { + const statusByKey = new Map(); + if (!targetPodGroup.podGroup) { + const fullName = nodeKey(targetPodGroup); + const status = statusByKey.get(fullName); + if ((targetPodGroup.parentRefs || []).length === 0) { + targetPodGroup.root = targetPodGroup; + } + targetPodGroup.podGroup = { + pods: [] as models.Pod[], + fullName, + ...targetPodGroup.podGroup, + ...targetPodGroup, + info: (targetPodGroup.info || []).filter(i => !i.name.includes('Resource.')), + createdAt: targetPodGroup.createdAt, + resourceStatus: {health: targetPodGroup.health, status: status ? status.status : null}, + renderMenu: () => props.nodeMenu(targetPodGroup), + kind: targetPodGroup.kind, + type: 'parentResource', + name: targetPodGroup.name + }; + } + if (child.kind === 'Pod') { + const p: models.Pod = { + ...child, + fullName: nodeKey(child), + metadata: {name: child.name}, + spec: {nodeName: 'Unknown'}, + health: child.health ? child.health.status : 'Unknown' + } as models.Pod; + + // Get node name for Pod + child.info?.forEach(i => { + if (i.name === 'Node') { + p.spec.nodeName = i.value; + } + }); + targetPodGroup.podGroup.pods.push(p); + } +} + +function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: ResourceTreeNode & dagre.Node) { + const fullName = nodeKey(node); + let comparisonStatus: models.SyncStatusCode = null; + let healthState: models.HealthStatus = null; + if (node.status || node.health) { + comparisonStatus = node.status; + healthState = node.health; + } + const appNode = isAppNode(node); + const rootNode = !node.root; + let extLinks: string[] = props.app.status.summary.externalURLs; + if (rootNode) { + extLinks = getExternalUrls(props.app.metadata.annotations, props.app.status.summary.externalURLs); + } + const margin = 8; + let topExtra = 0; + const podGroup = node.podGroup; + if (podGroup) { + const numberOfRows = Math.ceil(podGroup.pods.length / 8); + topExtra = margin + (POD_NODE_HEIGHT / 2 + 30 * numberOfRows) / 2; + } + return ( +
props.onNodeClick && props.onNodeClick(fullName)} + className={classNames('application-resource-tree__node', { + 'active': fullName === props.selectedNodeFullName, + 'application-resource-tree__node--orphaned': node.orphaned + })} + title={describeNode(node)} + style={{left: node.x, top: node.y - topExtra, width: node.width, height: node.height}}> + +
+
+ +
+ {!rootNode &&
{ResourceLabel({kind: node.kind})}
} +
+
+ + {node.name} + +
+ + {node.hook && } + {healthState != null && } + {comparisonStatus != null && } + {appNode && !rootNode && ( + + {ctx => ( + + + + )} + + )} + + +
+
+ {node.createdAt || rootNode ? ( + + {node.createdAt || props.app.metadata.creationTimestamp} + + ) : null} + {(node.info || []) + .filter(tag => !tag.name.includes('Node')) + .slice(0, 4) + .map((tag, i) => ( + + {tag.value} + + ))} + {(node.info || []).length > 4 && ( + ( +
+ {i.name}: {i.value} +
+ ))} + key={node.uid}> + + More + +
+ )} +
+ {props.nodeMenu && ( +
+ ( + + )}> + {() => props.nodeMenu(node)} + +
+ )} +
+
+ {podGroup && ( +
+
+
+ {podGroup.pods.map(pod => ( + ( + + {pod.metadata.name} +
Health: {pod.health}
+ {pod.createdAt && ( + + Created: + + {pod.createdAt} + + ago ({{pod.createdAt}}) + + )} +
+ } + popperOptions={{ + modifiers: { + preventOverflow: { + enabled: true + }, + hide: { + enabled: false + }, + flip: { + enabled: false + } + } + }} + key={pod.metadata.name}> +
+ {isYoungerThanXMinutes(pod, 30) && ( + + )} +
+ +
+
+ + )} + items={[ + { + title: ( + + Info + + ), + action: () => props.onNodeClick(pod.fullName) + }, + { + title: ( + + Logs + + ), + action: () => { + props.appContext.apis.navigation.goto('.', {node: pod.fullName, tab: 'logs'}, {replace: true}); + } + }, + { + title: ( + + Delete + + ), + action: () => { + deletePodAction(pod, props.appContext, props.app.metadata.name); + } + } + ]} + /> + ))} +
+
PODS
+
+
+ )} +
+ + ); +} + function renderResourceNode(props: ApplicationResourceTreeProps, id: string, node: ResourceTreeNode & dagre.Node) { const fullName = nodeKey(node); let comparisonStatus: models.SyncStatusCode = null; @@ -524,8 +784,12 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => findNetworkTargets(networkNodes, parent.networkingInfo).forEach(child => { const children = childrenByParentKey.get(treeNodeKey(parent)) || []; hasParents.add(treeNodeKey(child)); - children.push(child); - childrenByParentKey.set(treeNodeKey(parent), children); + if (child.kind !== 'Pod' || !props.showCompactNodes) { + children.push(child); + childrenByParentKey.set(treeNodeKey(parent), children); + } else { + processPodGroup(parent, child, props); + } }); }); roots = networkNodes.filter(node => !hasParents.has(treeNodeKey(node))); @@ -600,8 +864,13 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => } node.parentRefs.forEach(parent => { const children = childrenByParentKey.get(treeNodeKey(parent)) || []; - children.push(node); - childrenByParentKey.set(treeNodeKey(parent), children); + if (node.kind !== 'Pod' || !props.showCompactNodes) { + children.push(node); + childrenByParentKey.set(treeNodeKey(parent), children); + } else { + const parentTreeNode = nodeByKey.get(treeNodeKey(parent)); + processPodGroup(parentTreeNode, node, props); + } }); } }); @@ -622,7 +891,12 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => } function processNode(node: ResourceTreeNode, root: ResourceTreeNode, colors?: string[]) { - graph.setNode(treeNodeKey(node), {...node, width: NODE_WIDTH, height: NODE_HEIGHT, root}); + if (props.showCompactNodes && node.podGroup) { + const numberOfRows = Math.ceil(node.podGroup.pods.length / 8); + graph.setNode(treeNodeKey(node), {...node, type: NODE_TYPES.podGroup, width: NODE_WIDTH, height: POD_NODE_HEIGHT + 30 * numberOfRows, root}); + } else { + graph.setNode(treeNodeKey(node), {...node, width: NODE_WIDTH, height: NODE_HEIGHT, root}); + } (childrenByParentKey.get(treeNodeKey(node)) || []).sort(compareNodes).forEach(child => { if (treeNodeKey(child) === treeNodeKey(root)) { return; @@ -686,6 +960,8 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => return {renderLoadBalancerNode(node as any)}; case NODE_TYPES.groupedNodes: return {renderGroupedNodes(props, node as any)}; + case NODE_TYPES.podGroup: + return {renderPodGroup(props, key, node as ResourceTreeNode & dagre.Node)}; default: return {renderResourceNode(props, key, node as ResourceTreeNode & dagre.Node)}; } diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 49b868f4ccfb6..6dd9d9afb6a36 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -10,7 +10,7 @@ import {debounceTime} from 'rxjs/operators'; import {AppContext, Context, ContextApis} from '../../shared/context'; import {ResourceTreeNode} from './application-resource-tree/application-resource-tree'; -import {COLORS, ErrorNotification, Revision} from '../../shared/components'; +import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../shared/components'; import * as appModels from '../../shared/models'; import {services} from '../../shared/services'; @@ -242,6 +242,34 @@ export function findChildPod(node: appModels.ResourceNode, tree: appModels.Appli }); } +export const deletePodAction = async (pod: appModels.Pod, appContext: AppContext, appName: string) => { + appContext.apis.popup.prompt( + 'Delete pod', + () => ( +
+

Are you sure you want to delete Pod '{pod.name}'?

+
+ + +
+
+ ), + { + submit: async (vals, _, close) => { + try { + await services.applications.deleteResource(appName, pod, !!vals.force, false); + close(); + } catch (e) { + appContext.apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + } + ); +}; + export const deletePopup = async (ctx: ContextApis, resource: ResourceTreeNode, application: appModels.Application, appChanged?: BehaviorSubject) => { const isManaged = !!resource.status; const deleteOptions = {