From d49b9db8bcd6961d862c5ea3e95008a48c6bf4df Mon Sep 17 00:00:00 2001 From: ricoberger Date: Mon, 26 Jul 2021 19:22:42 +0200 Subject: [PATCH] Add support for Kiali metrics The Kiali plugin supports all the metrics from Kiali now. This means when a user selects an edge or node in the Kiali topology graph he also sees the metrics for TCP, HTTP and gRPC traffic. To get these metrics we are using the 'metrics' endpoint of the configured Kiali instance. Instead of the custom duration dropdown we are now using the Option component from the core package to select the duration (time range) for which the graph should be rendered. It is important to now that the time range only works for the metrics, for the values in the topology graph only the difference between the start and end time is used. --- CHANGELOG.md | 1 + docs/plugins/kiali.md | 1 - plugins/kiali/kiali.go | 24 ++- plugins/kiali/package.json | 1 + plugins/kiali/pkg/instance/instance.go | 16 ++ plugins/kiali/src/components/page/Page.tsx | 19 +- .../kiali/src/components/page/PageToolbar.tsx | 44 +++- .../components/page/PageToolbarDuration.tsx | 65 ------ plugins/kiali/src/components/panel/Graph.tsx | 22 +- .../src/components/panel/GraphActions.tsx | 16 +- .../src/components/panel/GraphWrapper.tsx | 15 +- plugins/kiali/src/components/panel/Panel.tsx | 15 +- .../src/components/panel/details/Chart.tsx | 78 +++++++ .../src/components/panel/details/Edge.tsx | 20 +- .../panel/details/EdgeMetricsHTTP.tsx | 168 +++++++++++++++ .../panel/details/EdgeMetricsTCP.tsx | 159 +++++++++++++++ .../panel/details/EdgeMetricsgRPC.tsx | 168 +++++++++++++++ .../src/components/panel/details/Node.tsx | 44 +++- .../components/panel/details/NodeMetrics.tsx | 193 ++++++++++++++++++ .../panel/details/NodeMetricsWrapper.tsx | 119 +++++++++++ .../panel/details/NodeTrafficGRPC.tsx | 88 ++++++++ .../panel/details/NodeTrafficHTTP.tsx | 88 ++++++++ plugins/kiali/src/utils/colors.ts | 61 ++++++ plugins/kiali/src/utils/helpers.ts | 86 +++++++- plugins/kiali/src/utils/interfaces.ts | 66 +++++- yarn.lock | 23 +++ 26 files changed, 1473 insertions(+), 127 deletions(-) delete mode 100644 plugins/kiali/src/components/page/PageToolbarDuration.tsx create mode 100644 plugins/kiali/src/components/panel/details/Chart.tsx create mode 100644 plugins/kiali/src/components/panel/details/EdgeMetricsHTTP.tsx create mode 100644 plugins/kiali/src/components/panel/details/EdgeMetricsTCP.tsx create mode 100644 plugins/kiali/src/components/panel/details/EdgeMetricsgRPC.tsx create mode 100644 plugins/kiali/src/components/panel/details/NodeMetrics.tsx create mode 100644 plugins/kiali/src/components/panel/details/NodeMetricsWrapper.tsx create mode 100644 plugins/kiali/src/components/panel/details/NodeTrafficGRPC.tsx create mode 100644 plugins/kiali/src/components/panel/details/NodeTrafficHTTP.tsx create mode 100644 plugins/kiali/src/utils/colors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8ad30f3..4769b4b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#91](https://github.com/kobsio/kobs/pull/91): Add force delete option for Kubernetes resources. - [#92](https://github.com/kobsio/kobs/pull/92): Preparation to build a own version of kobs using the [kobsio/app](https://github.com/kobsio/app) template. - [#93](https://github.com/kobsio/kobs/pull/93): Show status of Kubernetes resource in the table of the resources plugin. +- [#97](https://github.com/kobsio/kobs/pull/97): Add support for Kiali metrics. ### Fixed diff --git a/docs/plugins/kiali.md b/docs/plugins/kiali.md index 84bb0adf3..b0be72add 100644 --- a/docs/plugins/kiali.md +++ b/docs/plugins/kiali.md @@ -12,4 +12,3 @@ The following options can be used for a panel with the Kiali plugin: | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | | namespaces | string | A list of namespaces for which the topology graph should be shown. | Yes | -| duration | number | The duration for the metrics in the topology chart in seconds (e.g. `900`). If no duration is provided the duration will be calculated be the selected time range in the dashboard. | No | diff --git a/plugins/kiali/kiali.go b/plugins/kiali/kiali.go index f62404b69..df5eb66e5 100644 --- a/plugins/kiali/kiali.go +++ b/plugins/kiali/kiali.go @@ -75,7 +75,7 @@ func (router *Router) getGraph(w http.ResponseWriter, r *http.Request) { appenders := r.URL.Query()["appender"] namespaces := r.URL.Query()["namespace"] - log.WithFields(logrus.Fields{"name": name}).Tracef("getNamespaces") + log.WithFields(logrus.Fields{"name": name}).Tracef("getGraph") i := router.getInstance(name) if i == nil { @@ -104,6 +104,27 @@ func (router *Router) getGraph(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, graph) } +func (router *Router) getMetrics(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + url := r.URL.Query().Get("url") + + log.WithFields(logrus.Fields{"name": name, "url": url}).Tracef("getMetrics") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + metrics, err := i.GetMetrics(r.Context(), url) + if err != nil { + errresponse.Render(w, r, err, http.StatusInternalServerError, "Could not get metrics") + return + } + + render.JSON(w, r, metrics) +} + // Register returns a new router which can be used in the router for the kobs rest api. func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config) chi.Router { var instances []*instance.Instance @@ -132,6 +153,7 @@ func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Confi router.Get("/namespaces/{name}", router.getNamespaces) router.Get("/graph/{name}", router.getGraph) + router.Get("/metrics/{name}", router.getMetrics) return router } diff --git a/plugins/kiali/package.json b/plugins/kiali/package.json index 5b06e20c7..37ff3e7f2 100644 --- a/plugins/kiali/package.json +++ b/plugins/kiali/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@kobsio/plugin-core": "*", + "@nivo/line": "^0.73.0", "@patternfly/react-core": "^4.128.2", "@patternfly/react-icons": "^4.10.11", "@types/cytoscape": "^3.14.17", diff --git a/plugins/kiali/pkg/instance/instance.go b/plugins/kiali/pkg/instance/instance.go index d037ea62c..9b600c8bd 100644 --- a/plugins/kiali/pkg/instance/instance.go +++ b/plugins/kiali/pkg/instance/instance.go @@ -250,6 +250,22 @@ func (i *Instance) GetGraph(ctx context.Context, duration int64, graphType, grou return &graph, nil } +// GetMetrics returns the metrics for an edge or node in the Kiali topology graph. +func (i *Instance) GetMetrics(ctx context.Context, url string) (*map[string]interface{}, error) { + body, err := i.doRequest(ctx, url) + if err != nil { + return nil, err + } + + var metrics map[string]interface{} + err = json.Unmarshal(body, &metrics) + if err != nil { + return nil, err + } + + return &metrics, nil +} + // New returns a new Kiali instance for the given configuration. func New(config Config) (*Instance, error) { roundTripper := roundtripper.DefaultRoundTripper diff --git a/plugins/kiali/src/components/page/Page.tsx b/plugins/kiali/src/components/page/Page.tsx index 9889672ae..e65e78d28 100644 --- a/plugins/kiali/src/components/page/Page.tsx +++ b/plugins/kiali/src/components/page/Page.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import GraphWrapper from '../panel/GraphWrapper'; -import { IPanelOptions } from '../../utils/interfaces'; +import { IOptions } from '../../utils/interfaces'; import { IPluginPageProps } from '@kobsio/plugin-core'; import PageToolbar from './PageToolbar'; import { getOptionsFromSearch } from '../../utils/helpers'; @@ -18,17 +18,19 @@ import { getOptionsFromSearch } from '../../utils/helpers'; const Page: React.FunctionComponent = ({ name, displayName, description }: IPluginPageProps) => { const location = useLocation(); const history = useHistory(); - const [options, setOptions] = useState(getOptionsFromSearch(location.search)); + const [options, setOptions] = useState(getOptionsFromSearch(location.search)); const [details, setDetails] = useState(undefined); // changeOptions is used to change the options to get a list of traces from Jaeger. Instead of directly modifying the // options state we change the URL parameters. - const changeOptions = (opts: IPanelOptions): void => { + const changeOptions = (opts: IOptions): void => { const namespaces = opts.namespaces ? opts.namespaces.map((namespace) => `&namespace=${namespace}`) : []; history.push({ pathname: location.pathname, - search: `?duration=${opts.duration}${namespaces.length > 0 ? namespaces.join('') : ''}`, + search: `?time=${opts.times.time}&timeStart=${opts.times.timeStart}&timeEnd=${opts.times.timeEnd}${ + namespaces.length > 0 ? namespaces.join('') : '' + }`, }); }; @@ -45,12 +47,7 @@ const Page: React.FunctionComponent = ({ name, displayName, de {displayName}

{description}

- + @@ -61,7 +58,7 @@ const Page: React.FunctionComponent = ({ name, displayName, de ) : null} diff --git a/plugins/kiali/src/components/page/PageToolbar.tsx b/plugins/kiali/src/components/page/PageToolbar.tsx index 42a1006af..89fe000d9 100644 --- a/plugins/kiali/src/components/page/PageToolbar.tsx +++ b/plugins/kiali/src/components/page/PageToolbar.tsx @@ -10,24 +10,24 @@ import { import { FilterIcon, SearchIcon } from '@patternfly/react-icons'; import React, { useState } from 'react'; -import { IPanelOptions } from '../../utils/interfaces'; -import PageToolbarDuration from './PageToolbarDuration'; +import { IOptionsAdditionalFields, Options, TTime } from '@kobsio/plugin-core'; +import { IOptions } from '../../utils/interfaces'; import PageToolbarNamespaces from './PageToolbarNamespaces'; -interface IPageToolbarProps extends IPanelOptions { +interface IPageToolbarProps extends IOptions { name: string; - setOptions: (data: IPanelOptions) => void; + setOptions: (data: IOptions) => void; } const PageToolbar: React.FunctionComponent = ({ name, - duration, + times, namespaces, setOptions, }: IPageToolbarProps) => { - const [data, setData] = useState({ - duration: duration, + const [data, setData] = useState({ namespaces: namespaces, + times: times, }); // selectNamespace adds/removes the given namespace to the list of selected namespaces. When the namespace value is an @@ -48,6 +48,28 @@ const PageToolbar: React.FunctionComponent = ({ } }; + const changeOptions = ( + refresh: boolean, + additionalFields: IOptionsAdditionalFields[] | undefined, + time: TTime, + timeEnd: number, + timeStart: number, + ): void => { + const tmpData = { ...data }; + + if (refresh) { + setOptions({ + ...tmpData, + times: { time: time, timeEnd: timeEnd, timeStart: timeStart }, + }); + } + + setData({ + ...tmpData, + times: { time: time, timeEnd: timeEnd, timeStart: timeStart }, + }); + }; + return ( @@ -57,9 +79,11 @@ const PageToolbar: React.FunctionComponent = ({ - setData({ ...data, duration: d })} + diff --git a/plugins/kiali/src/components/page/PageToolbarDuration.tsx b/plugins/kiali/src/components/page/PageToolbarDuration.tsx deleted file mode 100644 index 4deb9e155..000000000 --- a/plugins/kiali/src/components/page/PageToolbarDuration.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useState } from 'react'; -import { Select, SelectOption, SelectOptionObject, SelectVariant } from '@patternfly/react-core'; - -interface IKialiPageToolbarDurationProps { - duration: number; - setDuration: (duration: number) => void; -} - -const KialiPageToolbarDuration: React.FunctionComponent = ({ - duration, - setDuration, -}: IKialiPageToolbarDurationProps) => { - const [show, setShow] = useState(false); - - return ( - - ); -}; - -export default KialiPageToolbarDuration; diff --git a/plugins/kiali/src/components/panel/Graph.tsx b/plugins/kiali/src/components/panel/Graph.tsx index 9311b449d..88c7cbf40 100644 --- a/plugins/kiali/src/components/panel/Graph.tsx +++ b/plugins/kiali/src/components/panel/Graph.tsx @@ -4,8 +4,9 @@ import cytoscape from 'cytoscape'; // eslint-disable-next-line @typescript-eslint/no-explicit-any import dagre from 'cytoscape-dagre'; -import { INodeData, INodeWrapper } from '../../utils/interfaces'; +import { IEdgeWrapper, INodeData, INodeWrapper } from '../../utils/interfaces'; import Edge from './details/Edge'; +import { IPluginTimes } from '@kobsio/plugin-core'; import Node from './details/Node'; cytoscape.use(dagre); @@ -179,13 +180,13 @@ const nodeLabel = (node: INodeData): string => { interface IGraphProps { name: string; - duration: number; + times: IPluginTimes; edges: cytoscape.ElementDefinition[]; nodes: cytoscape.ElementDefinition[]; setDetails?: (details: React.ReactNode) => void; } -const Graph: React.FunctionComponent = ({ name, duration, edges, nodes, setDetails }: IGraphProps) => { +const Graph: React.FunctionComponent = ({ name, times, edges, nodes, setDetails }: IGraphProps) => { const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const containerRef = useRef(null); @@ -200,14 +201,23 @@ const Graph: React.FunctionComponent = ({ name, duration, edges, no const data = ele.data(); if (data.nodeType && setDetails) { - setDetails( setDetails(undefined)} />); + setDetails( + setDetails(undefined)} + />, + ); } if (data.edgeType && setDetails) { setDetails( setDetails(undefined)} @@ -216,7 +226,7 @@ const Graph: React.FunctionComponent = ({ name, duration, edges, no } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [name, duration, nodes, setDetails], + [name, times, nodes, setDetails], ); const cyCallback = useCallback( diff --git a/plugins/kiali/src/components/panel/GraphActions.tsx b/plugins/kiali/src/components/panel/GraphActions.tsx index 0faee36d0..ea9f7c8a4 100644 --- a/plugins/kiali/src/components/panel/GraphActions.tsx +++ b/plugins/kiali/src/components/panel/GraphActions.tsx @@ -2,16 +2,18 @@ import { CardActions, Dropdown, DropdownItem, KebabToggle } from '@patternfly/re import React, { useState } from 'react'; import { Link } from 'react-router-dom'; +import { IPluginTimes } from '@kobsio/plugin-core'; + interface IGraphActionsProps { name: string; namespaces: string[]; - duration: number; + times: IPluginTimes; } export const GraphActions: React.FunctionComponent = ({ name, namespaces, - duration, + times, }: IGraphActionsProps) => { const [show, setShow] = useState(false); const namespaceParams = namespaces ? namespaces.map((namespace) => `&namespace=${namespace}`) : []; @@ -26,7 +28,15 @@ export const GraphActions: React.FunctionComponent = ({ dropdownItems={[ Explore} + component={ + + Explore + + } />, ]} /> diff --git a/plugins/kiali/src/components/panel/GraphWrapper.tsx b/plugins/kiali/src/components/panel/GraphWrapper.tsx index a52aaa0b6..ec9139689 100644 --- a/plugins/kiali/src/components/panel/GraphWrapper.tsx +++ b/plugins/kiali/src/components/panel/GraphWrapper.tsx @@ -5,28 +5,31 @@ import cytoscape from 'cytoscape'; import Graph from './Graph'; import { IGraph } from '../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; interface IGraphWrapperProps { name: string; namespaces: string[]; - duration: number; + times: IPluginTimes; setDetails?: (details: React.ReactNode) => void; } const GraphWrapper: React.FunctionComponent = ({ name, namespaces, - duration, + times, setDetails, }: IGraphWrapperProps) => { const { isError, isLoading, error, data, refetch } = useQuery( - ['jaeger/traces', name, duration, namespaces], + ['kiali/graph', name, times, namespaces], async () => { try { const namespaceParams = namespaces.map((namespace) => `namespace=${namespace}`).join('&'); const response = await fetch( - `/api/plugins/kiali/graph/${name}?duration=${duration}&graphType=versionedApp&injectServiceNodes=true&groupBy=app${[ + `/api/plugins/kiali/graph/${name}?duration=${ + times.timeEnd - times.timeStart + }&graphType=versionedApp&injectServiceNodes=true&groupBy=app${[ 'deadNode', 'sidecarsCheck', 'serviceEntry', @@ -87,7 +90,7 @@ const GraphWrapper: React.FunctionComponent = ({
= ({ export default memo(GraphWrapper, (prevProps, nextProps) => { if ( JSON.stringify(prevProps.namespaces) === JSON.stringify(nextProps.namespaces) && - prevProps.duration === nextProps.duration + JSON.stringify(prevProps.times) === JSON.stringify(nextProps.times) ) { return true; } diff --git a/plugins/kiali/src/components/panel/Panel.tsx b/plugins/kiali/src/components/panel/Panel.tsx index 9b0010d52..46564f5e2 100644 --- a/plugins/kiali/src/components/panel/Panel.tsx +++ b/plugins/kiali/src/components/panel/Panel.tsx @@ -33,20 +33,9 @@ export const Panel: React.FunctionComponent = ({ title={title} description={description} transparent={true} - actions={ - - } + actions={} > - + ); }; diff --git a/plugins/kiali/src/components/panel/details/Chart.tsx b/plugins/kiali/src/components/panel/details/Chart.tsx new file mode 100644 index 000000000..df99908d1 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/Chart.tsx @@ -0,0 +1,78 @@ +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import React from 'react'; +import { ResponsiveLineCanvas } from '@nivo/line'; +import { SquareIcon } from '@patternfly/react-icons'; + +import { COLOR_SCALE } from '../../../utils/colors'; +import { IChart } from '../../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; +import { formatAxisBottom } from '../../../utils/helpers'; + +interface IChartProps { + times: IPluginTimes; + chart: IChart; +} + +export const Chart: React.FunctionComponent = ({ times, chart }: IChartProps) => { + return ( + + {chart.title} + +
+ -.2f', + legend: chart.unit, + legendOffset: -40, + legendPosition: 'middle', + }} + colors={COLOR_SCALE} + curve="monotoneX" + data={chart.series} + enableArea={false} + enableGridX={false} + enableGridY={true} + enablePoints={false} + xFormat="time:%Y-%m-%d %H:%M:%S" + lineWidth={1} + margin={{ bottom: 25, left: 50, right: 0, top: 0 }} + theme={{ + background: '#ffffff', + fontFamily: 'RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif', + fontSize: 10, + textColor: '#000000', + }} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + tooltip={(tooltip) => ( +
+
+ {tooltip.point.data.xFormatted} +
+
+ {' '} + {chart.series.filter((serie) => serie.id === tooltip.point.serieId)[0].label}:{' '} + {tooltip.point.data.yFormatted} {chart.unit} +
+
+ )} + xScale={{ type: 'time' }} + yScale={{ max: 'auto', min: 'auto', stacked: false, type: 'linear' }} + yFormat=" >-.2f" + /> +
+
+
+ ); +}; + +export default Chart; diff --git a/plugins/kiali/src/components/panel/details/Edge.tsx b/plugins/kiali/src/components/panel/details/Edge.tsx index 53c0d059e..f8659e742 100644 --- a/plugins/kiali/src/components/panel/details/Edge.tsx +++ b/plugins/kiali/src/components/panel/details/Edge.tsx @@ -14,20 +14,24 @@ import React, { useState } from 'react'; import { IEdgeData, INodeWrapper } from '../../../utils/interfaces'; import EdgeFlags from './EdgeFlags'; import EdgeHosts from './EdgeHosts'; +import EdgeMetricsHTTP from './EdgeMetricsHTTP'; +import EdgeMetricsTCP from './EdgeMetricsTCP'; +import EdgeMetricsgRPC from './EdgeMetricsgRPC'; import EdgeTrafficGRPC from './EdgeTrafficGRPC'; import EdgeTrafficHTTP from './EdgeTrafficHTTP'; +import { IPluginTimes } from '@kobsio/plugin-core'; import { getTitle } from '../../../utils/helpers'; interface IEdgeProps { name: string; - duration: number; + times: IPluginTimes; edge: IEdgeData; nodes: INodeWrapper[]; close: () => void; } // Edge is used as the drawer panel component to display the details about a selected edge. -const Edge: React.FunctionComponent = ({ name, duration, edge, nodes, close }: IEdgeProps) => { +const Edge: React.FunctionComponent = ({ name, times, edge, nodes, close }: IEdgeProps) => { const [activeTab, setActiveTab] = useState( edge.traffic?.protocol === 'http' ? 'trafficHTTP' : edge.traffic?.protocol === 'grpc' ? 'trafficGRPC' : 'flags', ); @@ -94,6 +98,18 @@ const Edge: React.FunctionComponent = ({ name, duration, edge, nodes
+ +
+ {sourceNode.length === 1 && targetNode.length === 1 ? ( + edge.traffic?.protocol === 'tcp' ? ( + + ) : edge.traffic?.protocol === 'http' ? ( + + ) : edge.traffic?.protocol === 'grpc' ? ( + + ) : null + ) : null} +
); diff --git a/plugins/kiali/src/components/panel/details/EdgeMetricsHTTP.tsx b/plugins/kiali/src/components/panel/details/EdgeMetricsHTTP.tsx new file mode 100644 index 000000000..795faef83 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/EdgeMetricsHTTP.tsx @@ -0,0 +1,168 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { IChart, IMetricsMap, INodeWrapper, ISerie } from '../../../utils/interfaces'; +import { convertMetrics, getSteps } from '../../../utils/helpers'; +import Chart from './Chart'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +interface IEdgeMetricsHTTPProps { + name: string; + times: IPluginTimes; + sourceNode: INodeWrapper; + targetNode: INodeWrapper; +} + +const EdgeMetricsHTTP: React.FunctionComponent = ({ + name, + times, + sourceNode, + targetNode, +}: IEdgeMetricsHTTPProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['kiali/metrics/edge/http', name, times, sourceNode, targetNode], + async () => { + try { + if (targetNode.data?.namespace === 'unknown') { + return []; + } + + let nodeType = 'workloads'; + let nodeName = targetNode.data?.workload; + let byLabels = '&byLabels[]=destination_service_name'; + let reporter = 'destination'; + + if (targetNode.data?.nodeType === 'service') { + nodeType = 'services'; + nodeName = targetNode.data?.service; + byLabels = '&byLabels[]=source_workload'; + reporter = 'source'; + } + + if ((sourceNode.data, nodeType === 'unknown')) { + reporter = 'destination'; + } + + let filterKey = 'source_workload'; + let filterValue = sourceNode.data?.workload; + if (sourceNode.data?.workload === 'service') { + filterKey = 'destination_service_name'; + filterValue = sourceNode.data?.service; + } + + const response = await fetch( + `/api/plugins/kiali/metrics/${name}?url=${encodeURIComponent( + `/kiali/api/namespaces/${targetNode.data?.namespace}/${nodeType}/${nodeName}/metrics?queryTime=${ + times.timeEnd + }&duration=${times.timeEnd - times.timeStart}${getSteps( + times.timeStart, + times.timeEnd, + )}&quantiles[]=0.5&quantiles[]=0.95&quantiles[]=0.99&filters[]=request_count&filters[]=request_duration_millis&filters[]=request_error_count${byLabels}&direction=inbound&reporter=${reporter}&requestProtocol=http`, + )}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (!json) { + return []; + } + + const metrics: IMetricsMap = json; + const seriesCount: ISerie[] = []; + let seriesTime: ISerie[] = []; + + if (metrics.request_count) { + seriesCount.push( + ...convertMetrics(metrics.request_count.filter((metric) => metric.labels[filterKey] === filterValue)), + ); + } + + if (metrics.request_error_count) { + seriesCount.push( + ...convertMetrics( + metrics.request_error_count.filter((metric) => metric.labels[filterKey] === filterValue), + ), + ); + } + + if (metrics.request_duration_millis) { + seriesTime = convertMetrics( + metrics.request_duration_millis.filter((metric) => metric.labels[filterKey] === filterValue), + ); + } + + return [ + { + series: seriesCount, + title: 'HTTP Requests per Second', + unit: 'req/s', + }, + { + series: seriesTime, + title: 'HTTP Requests Response Time', + unit: 'ms', + }, + ]; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data || data.length === 0) { + return null; + } + + return ( +
+ {data.map( + (chart, index) => + chart.series.length > 0 && ( +
+ +

 

+
+ ), + )} +
+ ); +}; + +export default EdgeMetricsHTTP; diff --git a/plugins/kiali/src/components/panel/details/EdgeMetricsTCP.tsx b/plugins/kiali/src/components/panel/details/EdgeMetricsTCP.tsx new file mode 100644 index 000000000..2d4f7c9f7 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/EdgeMetricsTCP.tsx @@ -0,0 +1,159 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { IChart, IMetricsMap, INodeWrapper, ISerie } from '../../../utils/interfaces'; +import { convertMetrics, getSteps } from '../../../utils/helpers'; +import Chart from './Chart'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +interface IEdgeMetricsTCPProps { + name: string; + times: IPluginTimes; + sourceNode: INodeWrapper; + targetNode: INodeWrapper; +} + +const EdgeMetricsTCP: React.FunctionComponent = ({ + name, + times, + sourceNode, + targetNode, +}: IEdgeMetricsTCPProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['kiali/metrics/edge/tcp', name, times, sourceNode, targetNode], + async () => { + try { + if (targetNode.data?.namespace === 'unknown') { + return []; + } + + let nodeType = 'workloads'; + let nodeName = targetNode.data?.workload; + let byLabels = '&byLabels[]=destination_service_name'; + let direction = 'inbound'; + + if (targetNode.data?.nodeType === 'service') { + nodeType = 'services'; + nodeName = targetNode.data?.service; + byLabels = '&byLabels[]=source_workload'; + } else if (targetNode.data?.nodeType === 'serviceentry') { + nodeType = 'workloads'; + nodeName = sourceNode.data?.workload; + direction = 'outbound'; + } + + let filterKey = 'source_workload'; + let filterValue = sourceNode.data?.workload; + if (sourceNode.data?.nodeType === 'service') { + filterKey = 'destination_service_name'; + filterValue = sourceNode.data.service; + } else if (targetNode.data?.nodeType === 'serviceentry') { + filterKey = 'destination_service_name'; + filterValue = + targetNode.data.isServiceEntry?.hosts && targetNode.data.isServiceEntry?.hosts.length > 0 + ? targetNode.data.isServiceEntry?.hosts[0] + : ''; + } + + const response = await fetch( + `/api/plugins/kiali/metrics/${name}?url=${encodeURIComponent( + `/kiali/api/namespaces/${targetNode.data?.namespace}/${nodeType}/${nodeName}/metrics?queryTime=${ + times.timeEnd + }&duration=${times.timeEnd - times.timeStart}${getSteps( + times.timeStart, + times.timeEnd, + )}&quantiles[]=0.5&quantiles[]=0.95&quantiles[]=0.99&filters[]=tcp_sent&filters[]=tcp_received${byLabels}&direction=${direction}&reporter=source`, + )}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (!json) { + return []; + } + + const metrics: IMetricsMap = json; + const series: ISerie[] = []; + + if (metrics.tcp_received) { + series.push( + ...convertMetrics(metrics.tcp_received.filter((metric) => metric.labels[filterKey] === filterValue)), + ); + } + + if (metrics.tcp_sent) { + series.push( + ...convertMetrics(metrics.tcp_sent.filter((metric) => metric.labels[filterKey] === filterValue)), + ); + } + + return [ + { + series: series, + title: 'TCP Traffic', + unit: 'B/s', + }, + ]; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data || data.length === 0) { + return null; + } + + return ( +
+ {data.map( + (chart, index) => + chart.series.length > 0 && ( +
+ +

 

+
+ ), + )} +
+ ); +}; + +export default EdgeMetricsTCP; diff --git a/plugins/kiali/src/components/panel/details/EdgeMetricsgRPC.tsx b/plugins/kiali/src/components/panel/details/EdgeMetricsgRPC.tsx new file mode 100644 index 000000000..92052e424 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/EdgeMetricsgRPC.tsx @@ -0,0 +1,168 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { IChart, IMetricsMap, INodeWrapper, ISerie } from '../../../utils/interfaces'; +import { convertMetrics, getSteps } from '../../../utils/helpers'; +import Chart from './Chart'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +interface IEdgeMetricsgRPCProps { + name: string; + times: IPluginTimes; + sourceNode: INodeWrapper; + targetNode: INodeWrapper; +} + +const EdgeMetricsgRPC: React.FunctionComponent = ({ + name, + times, + sourceNode, + targetNode, +}: IEdgeMetricsgRPCProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['kiali/metrics/edge/http', name, times, sourceNode, targetNode], + async () => { + try { + if (targetNode.data?.namespace === 'unknown') { + return []; + } + + let nodeType = 'workloads'; + let nodeName = targetNode.data?.workload; + let byLabels = '&byLabels[]=destination_service_name'; + let reporter = 'destination'; + + if (targetNode.data?.nodeType === 'service') { + nodeType = 'services'; + nodeName = targetNode.data?.service; + byLabels = '&byLabels[]=source_workload'; + reporter = 'source'; + } + + if ((sourceNode.data, nodeType === 'unknown')) { + reporter = 'destination'; + } + + let filterKey = 'source_workload'; + let filterValue = sourceNode.data?.workload; + if (sourceNode.data?.workload === 'service') { + filterKey = 'destination_service_name'; + filterValue = sourceNode.data?.service; + } + + const response = await fetch( + `/api/plugins/kiali/metrics/${name}?url=${encodeURIComponent( + `/kiali/api/namespaces/${targetNode.data?.namespace}/${nodeType}/${nodeName}/metrics?queryTime=${ + times.timeEnd + }&duration=${times.timeEnd - times.timeStart}${getSteps( + times.timeStart, + times.timeEnd, + )}&quantiles[]=0.5&quantiles[]=0.95&quantiles[]=0.99&filters[]=request_count&filters[]=request_duration_millis&filters[]=request_error_count${byLabels}&direction=inbound&reporter=${reporter}&requestProtocol=grpc`, + )}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (!json) { + return []; + } + + const metrics: IMetricsMap = json; + const seriesCount: ISerie[] = []; + let seriesTime: ISerie[] = []; + + if (metrics.request_count) { + seriesCount.push( + ...convertMetrics(metrics.request_count.filter((metric) => metric.labels[filterKey] === filterValue)), + ); + } + + if (metrics.request_error_count) { + seriesCount.push( + ...convertMetrics( + metrics.request_error_count.filter((metric) => metric.labels[filterKey] === filterValue), + ), + ); + } + + if (metrics.request_duration_millis) { + seriesTime = convertMetrics( + metrics.request_duration_millis.filter((metric) => metric.labels[filterKey] === filterValue), + ); + } + + return [ + { + series: seriesCount, + title: 'gRPC Requests per Second', + unit: 'req/s', + }, + { + series: seriesTime, + title: 'gRPC Requests Response Time', + unit: 'ms', + }, + ]; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data || data.length === 0) { + return null; + } + + return ( +
+ {data.map( + (chart, index) => + chart.series.length > 0 && ( +
+ +

 

+
+ ), + )} +
+ ); +}; + +export default EdgeMetricsgRPC; diff --git a/plugins/kiali/src/components/panel/details/Node.tsx b/plugins/kiali/src/components/panel/details/Node.tsx index 011a48d5c..8b1e40b68 100644 --- a/plugins/kiali/src/components/panel/details/Node.tsx +++ b/plugins/kiali/src/components/panel/details/Node.tsx @@ -8,23 +8,55 @@ import { } from '@patternfly/react-core'; import React from 'react'; -import { INodeData } from '../../../utils/interfaces'; +import { IEdgeWrapper, INodeData, INodeWrapper } from '../../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; +import NodeMetricsWrapper from './NodeMetricsWrapper'; +import NodeTrafficGRPC from './NodeTrafficGRPC'; +import NodeTrafficHTTP from './NodeTrafficHTTP'; import { getTitle } from '../../../utils/helpers'; +interface IEdges { + source: IEdgeWrapper[]; + target: IEdgeWrapper[]; +} + +const getEdges = (node: INodeData, nodes: INodeWrapper[], edges: IEdgeWrapper[]): IEdges => { + if (node.nodeType === 'box') { + const childrens = nodes + .filter((children) => children.data?.parent === node.id) + .map((children) => children.data?.id || ''); + + return { + source: edges.filter((edge) => edge.data && childrens.includes(edge.data.source)), + target: edges.filter((edge) => edge.data && childrens.includes(edge.data.target)), + }; + } + + return { + source: edges.filter((edge) => edge.data?.source === node.id), + target: edges.filter((edge) => edge.data?.target === node.id), + }; +}; + interface IKialiDetailsNodeProps { name: string; - duration: number; + times: IPluginTimes; node: INodeData; + nodes: INodeWrapper[]; + edges: IEdgeWrapper[]; close: () => void; } const KialiDetailsNode: React.FunctionComponent = ({ name, - duration, + times, node, + nodes, + edges, close, }: IKialiDetailsNodeProps) => { const title = getTitle(node); + const filteredEdges = getEdges(node, nodes, edges); return ( @@ -40,7 +72,11 @@ const KialiDetailsNode: React.FunctionComponent = ({ - + + + + + ); }; diff --git a/plugins/kiali/src/components/panel/details/NodeMetrics.tsx b/plugins/kiali/src/components/panel/details/NodeMetrics.tsx new file mode 100644 index 000000000..c0eedb862 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/NodeMetrics.tsx @@ -0,0 +1,193 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { IChart, IMetricsMap, ISerie } from '../../../utils/interfaces'; +import { convertMetrics, getSteps } from '../../../utils/helpers'; +import Chart from './Chart'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +const getTCPChart = (nodeType: string, direction: string, metrics: IMetricsMap): IChart => { + const series: ISerie[] = []; + let title = ''; + + if (metrics.tcp_received) { + series.push(...convertMetrics(metrics.tcp_received)); + } + + if (metrics.tcp_sent) { + series.push(...convertMetrics(metrics.tcp_sent)); + } + + if (nodeType === 'services') { + title = 'TCP Traffic'; + } else { + if (direction === 'inbound') { + title = 'TCP Inbound Traffic'; + } else if (direction === 'outbound') { + title = 'TCP Outbound Traffic'; + } + } + + return { + series: series, + title: title, + unit: 'B/s', + }; +}; + +const getHTTPgRPCChart = ( + nodeType: string, + direction: string, + requestProtocol: string, + metrics: IMetricsMap, +): IChart => { + const series: ISerie[] = []; + let title = ''; + + if (metrics.request_count) { + series.push( + ...convertMetrics( + metrics.request_count.filter((metric) => metric.labels['request_protocol'] === requestProtocol), + ), + ); + } + + if (metrics.request_error_count) { + series.push( + ...convertMetrics( + metrics.request_error_count.filter((metric) => metric.labels['request_protocol'] === requestProtocol), + ), + ); + } + + if (nodeType === 'services') { + title = `${requestProtocol === 'http' ? 'HTTP' : 'gRPC'} Requests per Second`; + } else { + if (direction === 'inbound') { + title = `${requestProtocol === 'http' ? 'HTTP' : 'gRPC'} Inbound Requests per Second`; + } else if (direction === 'outbound') { + title = `${requestProtocol === 'http' ? 'HTTP' : 'gRPC'} Outbound Requests per Second`; + } + } + + return { + series: series, + title: title, + unit: 'req/s', + }; +}; + +interface INodeMetricsProps { + name: string; + times: IPluginTimes; + nodeNamespace: string; + nodeType: string; + nodeName: string; + filters: string; + byLabels: string; + direction: string; + reporter: string; +} + +const NodeMetrics: React.FunctionComponent = ({ + name, + times, + nodeNamespace, + nodeType, + nodeName, + filters, + byLabels, + direction, + reporter, +}: INodeMetricsProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['kiali/metrics/node', name, times, nodeNamespace, nodeType, nodeName, filters, byLabels, direction, reporter], + async () => { + try { + const response = await fetch( + `/api/plugins/kiali/metrics/${name}?url=${encodeURIComponent( + `/kiali/api/namespaces/${nodeNamespace}/${nodeType}/${nodeName}/metrics?queryTime=${ + times.timeEnd + }&duration=${times.timeEnd - times.timeStart}${getSteps( + times.timeStart, + times.timeEnd, + )}${filters}${byLabels}&direction=${direction}&reporter=${reporter}`, + )}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (!json) { + return []; + } + + const metrics: IMetricsMap = json; + + return [ + getTCPChart(nodeType, direction, metrics), + getHTTPgRPCChart(nodeType, direction, 'http', metrics), + getHTTPgRPCChart(nodeType, direction, 'grpc', metrics), + ]; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data || data.length === 0) { + return null; + } + + return ( +
+ {data.map( + (chart, index) => + chart.series.length > 0 && ( +
+ +

 

+
+ ), + )} +
+ ); +}; + +export default NodeMetrics; diff --git a/plugins/kiali/src/components/panel/details/NodeMetricsWrapper.tsx b/plugins/kiali/src/components/panel/details/NodeMetricsWrapper.tsx new file mode 100644 index 000000000..d2a6fb617 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/NodeMetricsWrapper.tsx @@ -0,0 +1,119 @@ +import React from 'react'; + +import { INodeData } from '../../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; +import NodeMetrics from './NodeMetrics'; + +interface INodeMetricsWrapperProps { + name: string; + times: IPluginTimes; + node: INodeData; +} + +const NodeMetricsWrapper: React.FunctionComponent = ({ + name, + times, + node, +}: INodeMetricsWrapperProps) => { + if (node.namespace === 'unknown') { + return null; + } + + if (node.nodeType === 'app') { + return ( + + + + + + ); + } else if (node.nodeType === 'service') { + return ( + + + + + ); + } else if (node.nodeType === 'box') { + return ( + + + + + ); + } + + return null; +}; + +export default NodeMetricsWrapper; diff --git a/plugins/kiali/src/components/panel/details/NodeTrafficGRPC.tsx b/plugins/kiali/src/components/panel/details/NodeTrafficGRPC.tsx new file mode 100644 index 000000000..317861658 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/NodeTrafficGRPC.tsx @@ -0,0 +1,88 @@ +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { IEdgeWrapper, ITrafficGRPCRates } from '../../../utils/interfaces'; + +interface IEdgeTraffic { + edges: number; + error: number; + rate: number; +} + +const getEdgeTraffic = (edges: IEdgeWrapper[]): IEdgeTraffic => { + let edgesCount = 0; + let error = 0; + let rate = 0; + + for (const edge of edges) { + if (edge.data && edge.data.traffic && edge.data.traffic.protocol === 'grpc') { + if (edge.data.traffic.rates) { + const rates = edge.data.traffic.rates as ITrafficGRPCRates; + edgesCount = edgesCount + 1; + error = error + (rates && rates.grpcPercentErr ? parseFloat(rates.grpcPercentErr) : 0); + rate = rate + parseFloat(rates.grpc); + } + } + } + + return { + edges: edgesCount, + error: error, + rate: rate, + }; +}; + +interface INodeTrafficGRPCProps { + sourceEdges: IEdgeWrapper[]; + targetEdges: IEdgeWrapper[]; +} + +const NodeTrafficGRPC: React.FunctionComponent = ({ + sourceEdges, + targetEdges, +}: INodeTrafficGRPCProps) => { + const grpcIn = getEdgeTraffic(targetEdges); + const grpcOut = getEdgeTraffic(sourceEdges); + + if (grpcIn.edges === 0 && grpcOut.edges === 0) { + return null; + } + + return ( + + + gRPC Requests per Second + + + + + + Total + Success (%) + Error (%) + + + + + In + {grpcIn.edges > 0 ? grpcIn.rate : '-'} + {grpcIn.edges > 0 ? 100 - grpcIn.error : '-'} + {grpcIn.edges > 0 ? grpcIn.error : '-'} + + + Out + {grpcOut.edges > 0 ? grpcOut.rate : '-'} + {grpcOut.edges > 0 ? 100 - grpcOut.error : '-'} + {grpcOut.edges > 0 ? grpcOut.error : '-'} + + + + + +

 

+
+ ); +}; + +export default NodeTrafficGRPC; diff --git a/plugins/kiali/src/components/panel/details/NodeTrafficHTTP.tsx b/plugins/kiali/src/components/panel/details/NodeTrafficHTTP.tsx new file mode 100644 index 000000000..e449ccdf7 --- /dev/null +++ b/plugins/kiali/src/components/panel/details/NodeTrafficHTTP.tsx @@ -0,0 +1,88 @@ +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { IEdgeWrapper, ITrafficHTTPRates } from '../../../utils/interfaces'; + +interface IEdgeTraffic { + edges: number; + error: number; + rate: number; +} + +const getEdgeTraffic = (edges: IEdgeWrapper[]): IEdgeTraffic => { + let edgesCount = 0; + let error = 0; + let rate = 0; + + for (const edge of edges) { + if (edge.data && edge.data.traffic && edge.data.traffic.protocol === 'http') { + if (edge.data.traffic.rates) { + const rates = edge.data.traffic.rates as ITrafficHTTPRates; + edgesCount = edgesCount + 1; + error = error + (rates && rates.httpPercentErr ? parseFloat(rates.httpPercentErr) : 0); + rate = rate + parseFloat(rates.http); + } + } + } + + return { + edges: edgesCount, + error: error, + rate: rate, + }; +}; + +interface INodeTrafficHTTPProps { + sourceEdges: IEdgeWrapper[]; + targetEdges: IEdgeWrapper[]; +} + +const NodeTrafficHTTP: React.FunctionComponent = ({ + sourceEdges, + targetEdges, +}: INodeTrafficHTTPProps) => { + const httpIn = getEdgeTraffic(targetEdges); + const httpOut = getEdgeTraffic(sourceEdges); + + if (httpIn.edges === 0 && httpOut.edges === 0) { + return null; + } + + return ( + + + HTTP Requests per Second + + + + + + Total + Success (%) + Error (%) + + + + + In + {httpIn.edges > 0 ? httpIn.rate : '-'} + {httpIn.edges > 0 ? 100 - httpIn.error : '-'} + {httpIn.edges > 0 ? httpIn.error : '-'} + + + Out + {httpOut.edges > 0 ? httpOut.rate : '-'} + {httpOut.edges > 0 ? 100 - httpOut.error : '-'} + {httpOut.edges > 0 ? httpOut.error : '-'} + + + + + +

 

+
+ ); +}; + +export default NodeTrafficHTTP; diff --git a/plugins/kiali/src/utils/colors.ts b/plugins/kiali/src/utils/colors.ts new file mode 100644 index 000000000..52beceacf --- /dev/null +++ b/plugins/kiali/src/utils/colors.ts @@ -0,0 +1,61 @@ +import chart_color_blue_100 from '@patternfly/react-tokens/dist/js/chart_color_blue_100'; +import chart_color_blue_200 from '@patternfly/react-tokens/dist/js/chart_color_blue_200'; +import chart_color_blue_300 from '@patternfly/react-tokens/dist/js/chart_color_blue_300'; +import chart_color_blue_400 from '@patternfly/react-tokens/dist/js/chart_color_blue_400'; +import chart_color_blue_500 from '@patternfly/react-tokens/dist/js/chart_color_blue_500'; +import chart_color_cyan_100 from '@patternfly/react-tokens/dist/js/chart_color_cyan_100'; +import chart_color_cyan_200 from '@patternfly/react-tokens/dist/js/chart_color_cyan_200'; +import chart_color_cyan_300 from '@patternfly/react-tokens/dist/js/chart_color_cyan_300'; +import chart_color_cyan_400 from '@patternfly/react-tokens/dist/js/chart_color_cyan_400'; +import chart_color_cyan_500 from '@patternfly/react-tokens/dist/js/chart_color_cyan_500'; +import chart_color_gold_100 from '@patternfly/react-tokens/dist/js/chart_color_gold_100'; +import chart_color_gold_200 from '@patternfly/react-tokens/dist/js/chart_color_gold_200'; +import chart_color_gold_300 from '@patternfly/react-tokens/dist/js/chart_color_gold_300'; +import chart_color_gold_400 from '@patternfly/react-tokens/dist/js/chart_color_gold_400'; +import chart_color_gold_500 from '@patternfly/react-tokens/dist/js/chart_color_gold_500'; +import chart_color_green_100 from '@patternfly/react-tokens/dist/js/chart_color_green_100'; +import chart_color_green_200 from '@patternfly/react-tokens/dist/js/chart_color_green_200'; +import chart_color_green_300 from '@patternfly/react-tokens/dist/js/chart_color_green_300'; +import chart_color_green_400 from '@patternfly/react-tokens/dist/js/chart_color_green_400'; +import chart_color_green_500 from '@patternfly/react-tokens/dist/js/chart_color_green_500'; +import chart_color_orange_100 from '@patternfly/react-tokens/dist/js/chart_color_orange_100'; +import chart_color_orange_200 from '@patternfly/react-tokens/dist/js/chart_color_orange_200'; +import chart_color_orange_300 from '@patternfly/react-tokens/dist/js/chart_color_orange_300'; +import chart_color_orange_400 from '@patternfly/react-tokens/dist/js/chart_color_orange_400'; +import chart_color_orange_500 from '@patternfly/react-tokens/dist/js/chart_color_orange_500'; + +// We are using the multi color ordered theme from Patternfly for the charts. +// See: https://github.com/patternfly/patternfly-react/blob/main/packages/react-charts/src/components/ChartTheme/themes/light/multi-color-ordered-theme.ts +export const COLOR_SCALE = [ + chart_color_blue_300.value, + chart_color_green_300.value, + chart_color_cyan_300.value, + chart_color_gold_300.value, + chart_color_orange_300.value, + chart_color_blue_100.value, + chart_color_green_500.value, + chart_color_cyan_100.value, + chart_color_gold_100.value, + chart_color_orange_500.value, + chart_color_blue_500.value, + chart_color_green_100.value, + chart_color_cyan_500.value, + chart_color_gold_500.value, + chart_color_orange_100.value, + chart_color_blue_200.value, + chart_color_green_400.value, + chart_color_cyan_200.value, + chart_color_gold_200.value, + chart_color_orange_400.value, + chart_color_blue_400.value, + chart_color_green_200.value, + chart_color_cyan_400.value, + chart_color_gold_400.value, + chart_color_orange_200.value, +]; + +// getColor returns the correct color for a given index. The function is mainly used by the legend for an chart, so that +// we can split the legend and chart into separate components. +export const getColor = (index: number): string => { + return COLOR_SCALE[index % COLOR_SCALE.length]; +}; diff --git a/plugins/kiali/src/utils/helpers.ts b/plugins/kiali/src/utils/helpers.ts index 3be83b4f2..8a4c847be 100644 --- a/plugins/kiali/src/utils/helpers.ts +++ b/plugins/kiali/src/utils/helpers.ts @@ -1,14 +1,25 @@ -import { INodeData, IPanelOptions } from './interfaces'; +import { IMetric, INodeData, IOptions, ISerie } from './interfaces'; +import { IPluginTimes, TTime, TTimeOptions } from '@kobsio/plugin-core'; // getOptionsFromSearch is used to get the Kiali options from a given search location. -export const getOptionsFromSearch = (search: string): IPanelOptions => { +export const getOptionsFromSearch = (search: string): IOptions => { const params = new URLSearchParams(search); - const duration = params.get('duration'); const namespaces = params.getAll('namespace'); + const time = params.get('time'); + const timeEnd = params.get('timeEnd'); + const timeStart = params.get('timeStart'); return { - duration: duration ? parseInt(duration) : 900, namespaces: namespaces.length > 0 ? namespaces : undefined, + times: { + time: time && TTimeOptions.includes(time) ? (time as TTime) : 'last15Minutes', + timeEnd: + time && TTimeOptions.includes(time) && timeEnd ? parseInt(timeEnd as string) : Math.floor(Date.now() / 1000), + timeStart: + time && TTimeOptions.includes(time) && timeStart + ? parseInt(timeStart as string) + : Math.floor(Date.now() / 1000) - 900, + }, }; }; @@ -28,3 +39,70 @@ export const getTitle = (node: INodeData): ITitle => { return { badge: 'A', title: node.app || '' }; }; + +// convertMetrics converts the data returned by the Kiali API to an array which we can use in our charting library +// (nivo). +export const convertMetrics = (metrics: IMetric[]): ISerie[] => { + const series: ISerie[] = []; + + for (const metric of metrics) { + series.push({ + data: metric.datapoints.map((datum) => { + return { x: new Date(datum[0] * 1000), y: datum[1] }; + }), + id: metric.name + metric.stat, + label: getMetricLabel(metric), + }); + } + + return series; +}; + +const getMetricLabel = (metric: IMetric): string => { + if (metric.name === 'tcp_received') { + return 'TCP Received'; + } else if (metric.name === 'tcp_sent') { + return 'TCP Send'; + } else if (metric.name === 'request_count') { + return 'Request Count'; + } else if (metric.name === 'request_error_count') { + return 'Request Error Count'; + } else if (metric.name === 'request_duration_millis') { + return metric.stat || 'Duration'; + } + + return ''; +}; + +// formatAxisBottom calculates the format for the bottom axis based on the specified start and end time. +export const formatAxisBottom = (times: IPluginTimes): string => { + if (times.timeEnd - times.timeStart < 3600) { + return '%H:%M:%S'; + } else if (times.timeEnd - times.timeStart < 86400) { + return '%H:%M'; + } else if (times.timeEnd - times.timeStart < 604800) { + return '%m-%d %H:%M'; + } + + return '%m-%d'; +}; + +export const getSteps = (start: number, end: number): string => { + const seconds = end - start; + + if (seconds <= 6 * 3600) { + return '&step=30&rateInterval=30s'; + } else if (seconds <= 12 * 3600) { + return '&step=60&rateInterval=60s'; + } else if (seconds <= 24 * 3600) { + return '&step=120&rateInterval=120s'; + } else if (seconds <= 2 * 24 * 3600) { + return '&step=300&rateInterval=300s'; + } else if (seconds <= 7 * 24 * 3600) { + return '&step=1800&rateInterval=1800s'; + } else if (seconds <= 30 * 24 * 3600) { + return '&step=3600&rateInterval=3600s'; + } + + return `&step=${seconds / 1000}&rateInterval=${seconds / 1000}s`; +}; diff --git a/plugins/kiali/src/utils/interfaces.ts b/plugins/kiali/src/utils/interfaces.ts index 711906ffb..9657933a5 100644 --- a/plugins/kiali/src/utils/interfaces.ts +++ b/plugins/kiali/src/utils/interfaces.ts @@ -1,8 +1,17 @@ +import { Serie } from '@nivo/line'; + +import { IPluginTimes } from '@kobsio/plugin-core'; + +// IOptions is the interface for the Kiali page. +export interface IOptions { + namespaces?: string[]; + times: IPluginTimes; +} + // IPanelOptions is the interface for the options property for the Kiali panel component. A user can set a list of // namespaces and a duration to overwrite the selected time range in the dashboard. export interface IPanelOptions { namespaces?: string[]; - duration?: number; } // IGraph is the interface for the Kiali topology graph including our custom fields. It should implement the same fields @@ -98,11 +107,18 @@ export interface ITraffic { export interface ITrafficHTTPRates { http: string; httpPercentErr?: string; + httpIn?: string; + httpIn4xx?: string; + httpIn5xx?: string; + httpInNoResponse?: string; + httpOut?: string; } export interface ITrafficGRPCRates { grpc: string; grpcPercentErr?: string; + grpcIn?: string; + grpcOut?: string; } export interface ITrafficTCPRates { @@ -132,3 +148,51 @@ export type IHealthConfig = { export type TNodeType = 'aggregate' | 'app' | 'box' | 'service' | 'serviceentry' | 'unknown' | 'workload'; export type TProtocols = 'http' | 'grpc' | 'tcp'; + +// IMetricsMap is the interface for the returned metrics from the Kiali API. +// See: https://github.com/kiali/kiali-ui/blob/master/src/types/Metrics.ts +export type IMetricsMap = { + // eslint-disable-next-line @typescript-eslint/naming-convention + request_count?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + request_error_count?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + request_duration_millis?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + request_throughput?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + response_throughput?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + request_size?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + response_size?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + tcp_received?: IMetric[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + tcp_sent?: IMetric[]; +}; + +export interface IMetric { + labels: ILabels; + datapoints: IDatapoint[]; + name: string; + stat?: string; +} + +export type ILabels = { + [key: string]: string; +}; + +export type IDatapoint = [number, number]; + +// IChart is the interface to render a chart for the returned data from the Kiali API. To get the serues data we have +// to use the convertMetrics function from the helpers.ts file. +export interface IChart { + series: ISerie[]; + title: string; + unit: string; +} + +export interface ISerie extends Serie { + label: string; +} diff --git a/yarn.lock b/yarn.lock index beed0cde1..2eef311ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2310,6 +2310,21 @@ "@react-spring/web" "9.2.0" d3-shape "^1.3.5" +"@nivo/line@^0.73.0": + version "0.73.0" + resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.73.0.tgz#8d77963d0ff014138b2201cc0836f0a12593225f" + integrity sha512-ZxCJyoYN6tFx3NA4KvxKtbYLLzEl+S8TU57Qd/iGfJ2AbFAgiBhOiYH4kBY7VuxbUjcqQhPUvkPGcolnzqEXMg== + dependencies: + "@nivo/annotations" "0.73.0" + "@nivo/axes" "0.73.0" + "@nivo/colors" "0.73.0" + "@nivo/legends" "0.73.0" + "@nivo/scales" "0.73.0" + "@nivo/tooltip" "0.73.0" + "@nivo/voronoi" "0.73.0" + "@react-spring/web" "9.2.4" + d3-shape "^1.3.5" + "@nivo/recompose@0.72.0": version "0.72.0" resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.72.0.tgz#dabd8eeaa99886e67d28162169521e2fa7e00d33" @@ -2383,6 +2398,14 @@ d3-delaunay "^5.3.0" d3-scale "^3.2.3" +"@nivo/voronoi@0.73.0": + version "0.73.0" + resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.73.0.tgz#858aa5340c93bc299e07938b1e6770394ca7e9c9" + integrity sha512-jYDwNkNhEXRt4LR3hTxBZpuRDUQN4GtdP4fxcJIFGxvmh3Bsd3XQMWPqZYTNpgDddYjsnLtLe5RvR5gC7hBojw== + dependencies: + d3-delaunay "^5.3.0" + d3-scale "^3.2.3" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"