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"