github.com/kiali/kiali@v1.84.0/graph/config/cytoscape/cytoscape.go (about)

     1  // Package cytoscape provides conversion from our graph to the CystoscapeJS
     2  // configuration json model.
     3  //
     4  // The following links are useful for understanding CytoscapeJS and it's configuration:
     5  //
     6  // Main page:   http://js.cytoscape.org/
     7  // JSON config: http://js.cytoscape.org/#notation/elements-json
     8  // Demos:       http://js.cytoscape.org/#demos
     9  //
    10  // Algorithm: Process the graph structure adding nodes and edges, decorating each
    11  //            with information provided.  An optional second pass generates compound
    12  //            nodes for requested boxing.
    13  //
    14  // The package provides the Cytoscape implementation of graph/ConfigVendor.
    15  
    16  package cytoscape
    17  
    18  import (
    19  	"crypto/md5"
    20  	"fmt"
    21  	"sort"
    22  	"strings"
    23  
    24  	"github.com/kiali/kiali/graph"
    25  )
    26  
    27  // ResponseFlags is a map of maps. Each response code is broken down by responseFlags:percentageOfTraffic, e.g.:
    28  // "200" : {
    29  //	"-"     : "80.0",
    30  //	"DC"    : "10.0",
    31  //	"FI,FD" : "10.0"
    32  // }, ...
    33  
    34  type ResponseFlags map[string]string
    35  
    36  // ResponseHosts is a map of maps. Each response host is broken down by responseFlags:percentageOfTraffic, e.g.:
    37  //
    38  //	"200" : {
    39  //	   "www.google.com" : "80.0",
    40  //	   "www.yahoo.com"  : "20.0"
    41  //	}, ...
    42  type ResponseHosts map[string]string
    43  
    44  // ResponseDetail holds information broken down by response code.
    45  type ResponseDetail struct {
    46  	Flags ResponseFlags `json:"flags,omitempty"`
    47  	Hosts ResponseHosts `json:"hosts,omitempty"`
    48  }
    49  
    50  // Responses maps responseCodes to detailed information for that code
    51  type Responses map[string]*ResponseDetail
    52  
    53  // ProtocolTraffic supplies all of the traffic information for a single protocol
    54  type ProtocolTraffic struct {
    55  	Protocol  string            `json:"protocol,omitempty"`  // protocol
    56  	Rates     map[string]string `json:"rates,omitempty"`     // map[rate]value
    57  	Responses Responses         `json:"responses,omitempty"` // see comment above
    58  }
    59  
    60  // GWInfo contains the resolved gateway configuration if the node represents an Istio gateway
    61  type GWInfo struct {
    62  	// IngressInfo contains the resolved gateway configuration if the node represents an Istio ingress gateway
    63  	IngressInfo GWInfoIngress `json:"ingressInfo,omitempty"`
    64  	// EgressInfo contains the resolved gateway configuration if the node represents an Istio egress gateway
    65  	EgressInfo GWInfoIngress `json:"egressInfo,omitempty"`
    66  	// GatewayAPIInfo contains the resolved gateway configuration if the node represents a Gateway API gateway
    67  	GatewayAPIInfo GWInfoIngress `json:"gatewayAPIInfo,omitempty"`
    68  }
    69  
    70  // GWInfoIngress contains the resolved gateway configuration if the node represents an Istio ingress gateway
    71  type GWInfoIngress struct {
    72  	// Hostnames is the list of hosts being served by the associated Istio gateways.
    73  	Hostnames []string `json:"hostnames,omitempty"`
    74  }
    75  
    76  // VSInfo contains the resolved VS configuration if the node has a VS attached.
    77  type VSInfo struct {
    78  	// Hostnames is the list of hostnames configured in the associated VSs
    79  	Hostnames []string `json:"hostnames,omitempty"`
    80  }
    81  
    82  // HealthConfig maps annotations information for health
    83  type HealthConfig map[string]string
    84  
    85  type NodeData struct {
    86  	// Cytoscape Fields
    87  	ID     string `json:"id"`               // unique internal node ID (n0, n1...)
    88  	Parent string `json:"parent,omitempty"` // Compound Node parent ID
    89  
    90  	// App Fields (not required by Cytoscape)
    91  	NodeType              string              `json:"nodeType"`
    92  	Cluster               string              `json:"cluster"`
    93  	Namespace             string              `json:"namespace"`
    94  	Workload              string              `json:"workload,omitempty"`
    95  	App                   string              `json:"app,omitempty"`
    96  	Version               string              `json:"version,omitempty"`
    97  	Service               string              `json:"service,omitempty"`               // requested service for NodeTypeService
    98  	Aggregate             string              `json:"aggregate,omitempty"`             // set like "<aggregate>=<aggregateVal>"
    99  	DestServices          []graph.ServiceName `json:"destServices,omitempty"`          // requested services for [dest] node
   100  	Labels                map[string]string   `json:"labels,omitempty"`                // k8s labels associated with the node
   101  	Traffic               []ProtocolTraffic   `json:"traffic,omitempty"`               // traffic rates for all detected protocols
   102  	HealthData            interface{}         `json:"healthData"`                      // data to calculate health status from configurations
   103  	HealthDataApp         interface{}         `json:"-"`                               // for local use to generate appBox health
   104  	HasCB                 bool                `json:"hasCB,omitempty"`                 // true (has circuit breaker) | false
   105  	HasFaultInjection     bool                `json:"hasFaultInjection,omitempty"`     // true (vs has fault injection) | false
   106  	HasHealthConfig       HealthConfig        `json:"hasHealthConfig,omitempty"`       // set to the health config override
   107  	HasMirroring          bool                `json:"hasMirroring,omitempty"`          // true (has mirroring) | false
   108  	HasRequestRouting     bool                `json:"hasRequestRouting,omitempty"`     // true (vs has request routing) | false
   109  	HasRequestTimeout     bool                `json:"hasRequestTimeout,omitempty"`     // true (vs has request timeout) | false
   110  	HasTCPTrafficShifting bool                `json:"hasTCPTrafficShifting,omitempty"` // true (vs has tcp traffic shifting) | false
   111  	HasTrafficShifting    bool                `json:"hasTrafficShifting,omitempty"`    // true (vs has traffic shifting) | false
   112  	HasVS                 *VSInfo             `json:"hasVS,omitempty"`                 // it can be empty if there is a VS without hostnames
   113  	HasWorkloadEntry      []graph.WEInfo      `json:"hasWorkloadEntry,omitempty"`      // static workload entry information | empty if there are no workload entries
   114  	IsBox                 string              `json:"isBox,omitempty"`                 // set for NodeTypeBox, current values: [ 'app', 'cluster', 'namespace' ]
   115  	IsDead                bool                `json:"isDead,omitempty"`                // true (has no pods) | false
   116  	IsGateway             *GWInfo             `json:"isGateway,omitempty"`             // Istio ingress/egress gateway information
   117  	IsIdle                bool                `json:"isIdle,omitempty"`                // true | false
   118  	IsInaccessible        bool                `json:"isInaccessible,omitempty"`        // true if the node exists in an inaccessible namespace
   119  	IsK8sGatewayAPI       bool                `json:"isK8sGatewayAPI,omitempty"`       // true (object is auto-generated from K8s API Gateway) | false
   120  	IsOutOfMesh           bool                `json:"isOutOfMesh,omitempty"`           // true (has missing sidecar) | false
   121  	IsOutside             bool                `json:"isOutside,omitempty"`             // true | false
   122  	IsRoot                bool                `json:"isRoot,omitempty"`                // true | false
   123  	IsServiceEntry        *graph.SEInfo       `json:"isServiceEntry,omitempty"`        // set static service entry information
   124  	IsWaypoint            bool                `json:"isWaypoint,omitempty"`            // true | false
   125  }
   126  
   127  type EdgeData struct {
   128  	// Cytoscape Fields
   129  	ID     string `json:"id"`     // unique internal edge ID (e0, e1...)
   130  	Source string `json:"source"` // parent node ID
   131  	Target string `json:"target"` // child node ID
   132  
   133  	// App Fields (not required by Cytoscape)
   134  	DestPrincipal   string          `json:"destPrincipal,omitempty"`   // principal used for the edge destination
   135  	IsMTLS          string          `json:"isMTLS,omitempty"`          // set to the percentage of traffic using a mutual TLS connection
   136  	ResponseTime    string          `json:"responseTime,omitempty"`    // in millis
   137  	SourcePrincipal string          `json:"sourcePrincipal,omitempty"` // principal used for the edge source
   138  	Throughput      string          `json:"throughput,omitempty"`      // in bytes/sec (request or response, depends on client request)
   139  	Traffic         ProtocolTraffic `json:"traffic,omitempty"`         // traffic rates for the edge protocol
   140  }
   141  
   142  type NodeWrapper struct {
   143  	Data *NodeData `json:"data"`
   144  }
   145  
   146  type EdgeWrapper struct {
   147  	Data *EdgeData `json:"data"`
   148  }
   149  
   150  type Elements struct {
   151  	Nodes []*NodeWrapper `json:"nodes"`
   152  	Edges []*EdgeWrapper `json:"edges"`
   153  }
   154  
   155  type Config struct {
   156  	Timestamp int64    `json:"timestamp"`
   157  	Duration  int64    `json:"duration"`
   158  	GraphType string   `json:"graphType"`
   159  	Elements  Elements `json:"elements"`
   160  }
   161  
   162  func nodeHash(id string) string {
   163  	return fmt.Sprintf("%x", md5.Sum([]byte(id)))
   164  }
   165  
   166  func edgeHash(from, to, protocol string) string {
   167  	return fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s.%s.%s", from, to, protocol))))
   168  }
   169  
   170  // NewConfig is required by the graph/ConfigVendor interface
   171  func NewConfig(trafficMap graph.TrafficMap, o graph.ConfigOptions) (result Config) {
   172  	nodes := []*NodeWrapper{}
   173  	edges := []*EdgeWrapper{}
   174  
   175  	buildConfig(trafficMap, &nodes, &edges, o)
   176  
   177  	// Add compound nodes as needed, inner boxes first
   178  	if strings.Contains(o.BoxBy, graph.BoxByApp) || o.GraphType == graph.GraphTypeApp || o.GraphType == graph.GraphTypeVersionedApp {
   179  		boxByApp(&nodes)
   180  	}
   181  	if strings.Contains(o.BoxBy, graph.BoxByNamespace) {
   182  		boxByNamespace(&nodes)
   183  	}
   184  	if strings.Contains(o.BoxBy, graph.BoxByCluster) {
   185  		boxByCluster(&nodes)
   186  	}
   187  
   188  	// sort nodes and edges for better json presentation (and predictable testing)
   189  	// kiali-1258 parent nodes must come before the child references
   190  	sort.Slice(nodes, func(i, j int) bool {
   191  		switch {
   192  		case nodes[i].Data.IsBox != nodes[j].Data.IsBox:
   193  			rank := func(boxBy string) int {
   194  				switch boxBy {
   195  				case graph.BoxByCluster:
   196  					return 0
   197  				case graph.BoxByNamespace:
   198  					return 1
   199  				case graph.BoxByApp:
   200  					return 2
   201  				default:
   202  					return 3
   203  				}
   204  			}
   205  			return rank(nodes[i].Data.IsBox) < rank(nodes[j].Data.IsBox)
   206  		case nodes[i].Data.Cluster != nodes[j].Data.Cluster:
   207  			return nodes[i].Data.Cluster < nodes[j].Data.Cluster
   208  		case nodes[i].Data.Namespace != nodes[j].Data.Namespace:
   209  			return nodes[i].Data.Namespace < nodes[j].Data.Namespace
   210  		case nodes[i].Data.App != nodes[j].Data.App:
   211  			return nodes[i].Data.App < nodes[j].Data.App
   212  		case nodes[i].Data.Version != nodes[j].Data.Version:
   213  			return nodes[i].Data.Version < nodes[j].Data.Version
   214  		case nodes[i].Data.Service != nodes[j].Data.Service:
   215  			return nodes[i].Data.Service < nodes[j].Data.Service
   216  		default:
   217  			return nodes[i].Data.Workload < nodes[j].Data.Workload
   218  		}
   219  	})
   220  	sort.Slice(edges, func(i, j int) bool {
   221  		switch {
   222  		case edges[i].Data.Source != edges[j].Data.Source:
   223  			return edges[i].Data.Source < edges[j].Data.Source
   224  		case edges[i].Data.Target != edges[j].Data.Target:
   225  			return edges[i].Data.Target < edges[j].Data.Target
   226  		default:
   227  			// source and target are the same, it must differ on protocol
   228  			return edges[i].Data.Traffic.Protocol < edges[j].Data.Traffic.Protocol
   229  		}
   230  	})
   231  
   232  	elements := Elements{nodes, edges}
   233  	result = Config{
   234  		Duration:  int64(o.Duration.Seconds()),
   235  		Timestamp: o.QueryTime,
   236  		GraphType: o.GraphType,
   237  		Elements:  elements,
   238  	}
   239  	return result
   240  }
   241  
   242  func buildConfig(trafficMap graph.TrafficMap, nodes *[]*NodeWrapper, edges *[]*EdgeWrapper, o graph.ConfigOptions) {
   243  	for id, n := range trafficMap {
   244  		nodeID := nodeHash(id)
   245  
   246  		nd := &NodeData{
   247  			ID:        nodeID,
   248  			NodeType:  n.NodeType,
   249  			Cluster:   n.Cluster,
   250  			Namespace: n.Namespace,
   251  			Workload:  n.Workload,
   252  			App:       n.App,
   253  			Version:   n.Version,
   254  			Service:   n.Service,
   255  		}
   256  
   257  		addNodeTelemetry(n, nd)
   258  
   259  		if val, ok := n.Metadata[graph.HealthData]; ok {
   260  			nd.HealthData = val
   261  		}
   262  		if val, ok := n.Metadata[graph.HealthDataApp]; ok {
   263  			nd.HealthDataApp = val
   264  		}
   265  
   266  		// set k8s labels, if any
   267  		if val, ok := n.Metadata[graph.Labels]; ok {
   268  			nd.Labels = val.(graph.LabelsMetadata)
   269  		}
   270  
   271  		// set annotations, if available
   272  		if val, ok := n.Metadata[graph.HasHealthConfig]; ok {
   273  			nd.HasHealthConfig = val.(map[string]string)
   274  		}
   275  
   276  		// node may have deployment but no pods running)
   277  		if val, ok := n.Metadata[graph.IsDead]; ok {
   278  			nd.IsDead = val.(bool)
   279  		}
   280  
   281  		// node may be idle
   282  		if val, ok := n.Metadata[graph.IsIdle]; ok {
   283  			nd.IsIdle = val.(bool)
   284  		}
   285  
   286  		// node may be a root
   287  		if val, ok := n.Metadata[graph.IsRoot]; ok {
   288  			nd.IsRoot = val.(bool)
   289  		}
   290  
   291  		// node is not accessible to the current user
   292  		if val, ok := n.Metadata[graph.IsInaccessible]; ok {
   293  			nd.IsInaccessible = val.(bool)
   294  		}
   295  
   296  		// node may represent an Istio Ingress Gateway
   297  		if ingGateways, ok := n.Metadata[graph.IsIngressGateway]; ok {
   298  			var configuredHostnames []string
   299  			for _, hosts := range ingGateways.(graph.GatewaysMetadata) {
   300  				configuredHostnames = append(configuredHostnames, hosts...)
   301  			}
   302  
   303  			nd.IsGateway = &GWInfo{
   304  				IngressInfo: GWInfoIngress{Hostnames: configuredHostnames},
   305  			}
   306  		} else if egrGateways, ok := n.Metadata[graph.IsEgressGateway]; ok {
   307  			// node may represent an Istio Egress Gateway
   308  			var configuredHostnames []string
   309  			for _, hosts := range egrGateways.(graph.GatewaysMetadata) {
   310  				configuredHostnames = append(configuredHostnames, hosts...)
   311  			}
   312  
   313  			nd.IsGateway = &GWInfo{
   314  				EgressInfo: GWInfoIngress{Hostnames: configuredHostnames},
   315  			}
   316  		} else if apiGateways, ok := n.Metadata[graph.IsGatewayAPI]; ok {
   317  			// node may represent a Gateway API
   318  			var configuredHostnames []string
   319  			for _, hosts := range apiGateways.(graph.GatewaysMetadata) {
   320  				configuredHostnames = append(configuredHostnames, hosts...)
   321  			}
   322  
   323  			nd.IsGateway = &GWInfo{
   324  				GatewayAPIInfo: GWInfoIngress{Hostnames: configuredHostnames},
   325  			}
   326  
   327  			nd.IsK8sGatewayAPI = true
   328  		}
   329  
   330  		// node may have a circuit breaker
   331  		if val, ok := n.Metadata[graph.HasCB]; ok {
   332  			nd.HasCB = val.(bool)
   333  		}
   334  
   335  		// node may have a virtual service
   336  		if virtualServices, ok := n.Metadata[graph.HasVS]; ok {
   337  
   338  			var configuredHostnames []string
   339  			for _, hosts := range virtualServices.(graph.VirtualServicesMetadata) {
   340  				configuredHostnames = append(configuredHostnames, hosts...)
   341  			}
   342  
   343  			nd.HasVS = &VSInfo{Hostnames: configuredHostnames}
   344  		}
   345  
   346  		// set mesh checks, if available
   347  		if val, ok := n.Metadata[graph.IsOutOfMesh]; ok {
   348  			nd.IsOutOfMesh = val.(bool)
   349  		}
   350  
   351  		// check if node is on another namespace
   352  		if val, ok := n.Metadata[graph.IsOutside]; ok {
   353  			nd.IsOutside = val.(bool)
   354  		}
   355  
   356  		// check if node is a waypoint proxy
   357  		if val, ok := n.Metadata[graph.IsWaypoint]; ok {
   358  			nd.IsWaypoint = val.(bool)
   359  		}
   360  
   361  		if val, ok := n.Metadata[graph.HasMirroring]; ok {
   362  			nd.HasMirroring = val.(bool)
   363  		}
   364  
   365  		if val, ok := n.Metadata[graph.HasRequestRouting]; ok {
   366  			nd.HasRequestRouting = val.(bool)
   367  		}
   368  
   369  		if val, ok := n.Metadata[graph.HasFaultInjection]; ok {
   370  			nd.HasFaultInjection = val.(bool)
   371  		}
   372  
   373  		if val, ok := n.Metadata[graph.HasTrafficShifting]; ok {
   374  			nd.HasTrafficShifting = val.(bool)
   375  		}
   376  
   377  		if val, ok := n.Metadata[graph.HasTCPTrafficShifting]; ok {
   378  			nd.HasTCPTrafficShifting = val.(bool)
   379  		}
   380  
   381  		if val, ok := n.Metadata[graph.HasRequestTimeout]; ok {
   382  			nd.HasRequestTimeout = val.(bool)
   383  		}
   384  
   385  		if val, ok := n.Metadata[graph.IsK8sGatewayAPI]; ok {
   386  			nd.IsK8sGatewayAPI = val.(bool)
   387  		}
   388  
   389  		// node may have destination service info
   390  		if val, ok := n.Metadata[graph.DestServices]; ok {
   391  			nd.DestServices = []graph.ServiceName{}
   392  			for _, val := range val.(graph.DestServicesMetadata) {
   393  				nd.DestServices = append(nd.DestServices, val)
   394  			}
   395  		}
   396  
   397  		// node may have service entry static info
   398  		if val, ok := n.Metadata[graph.IsServiceEntry]; ok {
   399  			nd.IsServiceEntry = val.(*graph.SEInfo)
   400  		}
   401  
   402  		// node may have a workload entry associated with it
   403  		if val, ok := n.Metadata[graph.HasWorkloadEntry]; ok {
   404  			nd.HasWorkloadEntry = []graph.WEInfo{}
   405  			if weInfo, ok := val.([]graph.WEInfo); ok {
   406  				nd.HasWorkloadEntry = append(nd.HasWorkloadEntry, weInfo...)
   407  			}
   408  		}
   409  
   410  		// node may be an aggregate
   411  		if n.NodeType == graph.NodeTypeAggregate {
   412  			nd.Aggregate = fmt.Sprintf("%s=%s", n.Metadata[graph.Aggregate].(string), n.Metadata[graph.AggregateValue].(string))
   413  		}
   414  
   415  		nw := NodeWrapper{
   416  			Data: nd,
   417  		}
   418  
   419  		*nodes = append(*nodes, &nw)
   420  
   421  		for _, e := range n.Edges {
   422  			sourceIDHash := nodeHash(n.ID)
   423  			destIDHash := nodeHash(e.Dest.ID)
   424  			protocol := ""
   425  			if e.Metadata[graph.ProtocolKey] != nil {
   426  				protocol = e.Metadata[graph.ProtocolKey].(string)
   427  			}
   428  			edgeID := edgeHash(sourceIDHash, destIDHash, protocol)
   429  			ed := EdgeData{
   430  				ID:     edgeID,
   431  				Source: sourceIDHash,
   432  				Target: destIDHash,
   433  				Traffic: ProtocolTraffic{
   434  					Protocol: protocol,
   435  				},
   436  			}
   437  			if e.Metadata[graph.DestPrincipal] != nil {
   438  				ed.DestPrincipal = e.Metadata[graph.DestPrincipal].(string)
   439  			}
   440  			if e.Metadata[graph.SourcePrincipal] != nil {
   441  				ed.SourcePrincipal = e.Metadata[graph.SourcePrincipal].(string)
   442  			}
   443  			addEdgeTelemetry(e, &ed)
   444  
   445  			ew := EdgeWrapper{
   446  				Data: &ed,
   447  			}
   448  			*edges = append(*edges, &ew)
   449  		}
   450  	}
   451  }
   452  
   453  func addNodeTelemetry(n *graph.Node, nd *NodeData) {
   454  	for _, p := range graph.Protocols {
   455  		protocolTraffic := ProtocolTraffic{Protocol: p.Name}
   456  		for _, r := range p.NodeRates {
   457  			if rateVal := getRate(n.Metadata, r.Name); rateVal > 0.0 {
   458  				if protocolTraffic.Rates == nil {
   459  					protocolTraffic.Rates = make(map[string]string)
   460  				}
   461  				protocolTraffic.Rates[string(r.Name)] = rateToString(r.Precision, rateVal)
   462  			}
   463  		}
   464  		if protocolTraffic.Rates != nil {
   465  			if nd.Traffic == nil {
   466  				nd.Traffic = []ProtocolTraffic{}
   467  			}
   468  			nd.Traffic = append(nd.Traffic, protocolTraffic)
   469  		}
   470  	}
   471  }
   472  
   473  func addEdgeTelemetry(e *graph.Edge, ed *EdgeData) {
   474  	if val, ok := e.Metadata[graph.IsMTLS]; ok {
   475  		ed.IsMTLS = fmt.Sprintf("%.0f", val.(float64))
   476  	}
   477  	if val, ok := e.Metadata[graph.ResponseTime]; ok {
   478  		responseTime := val.(float64)
   479  		ed.ResponseTime = fmt.Sprintf("%.0f", responseTime)
   480  	}
   481  	if val, ok := e.Metadata[graph.Throughput]; ok {
   482  		throughput := val.(float64)
   483  		ed.Throughput = fmt.Sprintf("%.0f", throughput)
   484  	}
   485  
   486  	// an edge represents traffic for at most one protocol
   487  	for _, p := range graph.Protocols {
   488  		protocolTraffic := ProtocolTraffic{Protocol: p.Name}
   489  		total := 0.0
   490  		err := 0.0
   491  		var percentErr, percentReq graph.Rate
   492  		for _, r := range p.EdgeRates {
   493  			rateVal := getRate(e.Metadata, r.Name)
   494  			switch {
   495  			case r.IsTotal:
   496  				// there is one field holding the total traffic
   497  				total = rateVal
   498  			case r.IsErr:
   499  				// error rates can be reported for several error status codes, so sum up all
   500  				// of the error traffic to be used in the percentErr calculation below.
   501  				err += rateVal
   502  			case r.IsPercentErr:
   503  				// hold onto the percentErr field so we know how to report it below
   504  				percentErr = r
   505  			case r.IsPercentReq:
   506  				// hold onto the percentReq field so we know how to report it below
   507  				percentReq = r
   508  			}
   509  			if rateVal > 0.0 {
   510  				if protocolTraffic.Rates == nil {
   511  					protocolTraffic.Rates = make(map[string]string)
   512  				}
   513  				protocolTraffic.Rates[string(r.Name)] = rateToString(r.Precision, rateVal)
   514  			}
   515  		}
   516  		if protocolTraffic.Rates != nil {
   517  			if total > 0 {
   518  				if percentErr.Name != "" {
   519  					rateVal := err / total * 100
   520  					if rateVal > 0.0 {
   521  						protocolTraffic.Rates[string(percentErr.Name)] = fmt.Sprintf("%.*f", percentErr.Precision, rateVal)
   522  					}
   523  				}
   524  				if percentReq.Name != "" {
   525  					rateVal := 0.0
   526  					for _, r := range p.NodeRates {
   527  						if !r.IsOut {
   528  							continue
   529  						}
   530  						rateVal = total / getRate(e.Source.Metadata, r.Name) * 100.0
   531  						break
   532  					}
   533  					if rateVal > 0.0 {
   534  						protocolTraffic.Rates[string(percentReq.Name)] = fmt.Sprintf("%.*f", percentReq.Precision, rateVal)
   535  					}
   536  				}
   537  				mdResponses := e.Metadata[p.EdgeResponses].(graph.Responses)
   538  				for code, detail := range mdResponses {
   539  					responseFlags := make(ResponseFlags)
   540  					responseHosts := make(ResponseHosts)
   541  					for flags, value := range detail.Flags {
   542  						responseFlags[flags] = fmt.Sprintf("%.*f", 1, value/total*100.0)
   543  					}
   544  					for host, value := range detail.Hosts {
   545  						responseHosts[host] = fmt.Sprintf("%.*f", 1, value/total*100.0)
   546  					}
   547  					responseDetail := &ResponseDetail{Flags: responseFlags, Hosts: responseHosts}
   548  					if protocolTraffic.Responses == nil {
   549  						protocolTraffic.Responses = Responses{code: responseDetail}
   550  					} else {
   551  						protocolTraffic.Responses[code] = responseDetail
   552  					}
   553  				}
   554  				ed.Traffic = protocolTraffic
   555  			}
   556  			break
   557  		}
   558  	}
   559  }
   560  
   561  func getRate(md graph.Metadata, k graph.MetadataKey) float64 {
   562  	if rate, ok := md[k]; ok {
   563  		return rate.(float64)
   564  	}
   565  	return 0.0
   566  }
   567  
   568  // boxByApp adds compound nodes to box nodes for the same app
   569  func boxByApp(nodes *[]*NodeWrapper) {
   570  	box := make(map[string][]*NodeData)
   571  
   572  	for _, nw := range *nodes {
   573  		if nw.Data.App != "unknown" && nw.Data.App != "" {
   574  			k := fmt.Sprintf("box_%s_%s_%s", nw.Data.Cluster, nw.Data.Namespace, nw.Data.App)
   575  			box[k] = append(box[k], nw.Data)
   576  		}
   577  	}
   578  
   579  	generateBoxCompoundNodes(box, nodes, graph.BoxByApp)
   580  }
   581  
   582  // boxByNamespace adds compound nodes to box nodes in the same namespace
   583  func boxByNamespace(nodes *[]*NodeWrapper) {
   584  	box := make(map[string][]*NodeData)
   585  
   586  	for _, nw := range *nodes {
   587  		// never box unknown
   588  		if nw.Data.Parent == "" && nw.Data.Namespace != graph.Unknown {
   589  			k := fmt.Sprintf("box_%s_%s", nw.Data.Cluster, nw.Data.Namespace)
   590  			box[k] = append(box[k], nw.Data)
   591  		}
   592  	}
   593  	if len(box) > 1 {
   594  		generateBoxCompoundNodes(box, nodes, graph.BoxByNamespace)
   595  	}
   596  }
   597  
   598  // boxByCluster adds compound nodes to box nodes in the same cluster
   599  func boxByCluster(nodes *[]*NodeWrapper) {
   600  	box := make(map[string][]*NodeData)
   601  
   602  	for _, nw := range *nodes {
   603  		// never box unknown
   604  		if nw.Data.Parent == "" && nw.Data.Cluster != graph.Unknown {
   605  			k := fmt.Sprintf("box_%s", nw.Data.Cluster)
   606  			box[k] = append(box[k], nw.Data)
   607  		}
   608  	}
   609  	if len(box) > 1 {
   610  		generateBoxCompoundNodes(box, nodes, graph.BoxByCluster)
   611  	}
   612  }
   613  
   614  func generateBoxCompoundNodes(box map[string][]*NodeData, nodes *[]*NodeWrapper, boxBy string) {
   615  	for k, members := range box {
   616  		if len(members) > 1 {
   617  			// create the compound (parent) node for the member nodes
   618  			nodeID := nodeHash(k)
   619  			namespace := ""
   620  			app := ""
   621  			switch boxBy {
   622  			case graph.BoxByNamespace:
   623  				namespace = members[0].Namespace
   624  			case graph.BoxByApp:
   625  				namespace = members[0].Namespace
   626  				app = members[0].App
   627  			}
   628  			nd := NodeData{
   629  				ID:        nodeID,
   630  				NodeType:  graph.NodeTypeBox,
   631  				Cluster:   members[0].Cluster,
   632  				Namespace: namespace,
   633  				App:       app,
   634  				Version:   "",
   635  				IsBox:     boxBy,
   636  			}
   637  
   638  			nw := NodeWrapper{
   639  				Data: &nd,
   640  			}
   641  
   642  			// assign each member node to the compound parent
   643  			nd.IsOutOfMesh = false // TODO: this is probably unecessarily noisy
   644  			nd.IsInaccessible = false
   645  			nd.IsOutside = false
   646  
   647  			for _, n := range members {
   648  				n.Parent = nodeID
   649  
   650  				// For logical boxing (app), copy some member attributes to to the box node
   651  				if boxBy == graph.BoxByApp {
   652  					// make sure to use app health for the app box
   653  					if nd.HealthData == nil && n.NodeType == graph.NodeTypeApp {
   654  						if graph.IsOK(n.Workload) {
   655  							// for versionedApp node, use the app health (n.HealthData has workload health)
   656  							nd.HealthData = n.HealthDataApp
   657  						} else {
   658  							// for app node just ue the node's health
   659  							nd.HealthData = n.HealthData
   660  						}
   661  					}
   662  					nd.IsOutOfMesh = nd.IsOutOfMesh || n.IsOutOfMesh
   663  					nd.IsInaccessible = nd.IsInaccessible || n.IsInaccessible
   664  					nd.IsOutside = nd.IsOutside || n.IsOutside
   665  				}
   666  			}
   667  
   668  			// add the compound node to the list of nodes
   669  			*nodes = append(*nodes, &nw)
   670  		}
   671  	}
   672  }
   673  
   674  func rateToString(minPrecision int, rateVal float64) string {
   675  	precision := minPrecision
   676  	if requiredPrecision := calcPrecision(rateVal, 5); requiredPrecision > minPrecision {
   677  		precision = requiredPrecision
   678  	}
   679  
   680  	return fmt.Sprintf("%.*f", precision, rateVal)
   681  }
   682  
   683  // calcPrecision returns the precision necessary to see at least one significant digit (up to max)
   684  func calcPrecision(val float64, max int) int {
   685  	if val <= 0 {
   686  		return 0
   687  	}
   688  
   689  	precision := 0
   690  	for precision < max {
   691  		if val >= 1 {
   692  			break
   693  		}
   694  		val *= 10
   695  		precision++
   696  	}
   697  	return precision
   698  }