Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NETOBSERV-787 UI: Table Histogram #271

Merged
merged 3 commits into from
Jan 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions pkg/loki/topology_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Topology struct {
limit string
rateInterval string
step string
function string
dataField string
fields string
}
Expand All @@ -30,11 +31,15 @@ func NewTopologyQuery(cfg *Config, start, end, limit, rateInterval, step, metric
l = topologyDefaultLimit
}

var t string
var f, t string
switch metricType {
case "count":
f = "count_over_time"
case "packets":
f = "rate"
t = "Packets"
default:
f = "rate"
t = "Bytes"
}

Expand All @@ -44,6 +49,7 @@ func NewTopologyQuery(cfg *Config, start, end, limit, rateInterval, step, metric
rateInterval: rateInterval,
step: step,
limit: l,
function: f,
dataField: t,
fields: getFields(scope, groups),
},
Expand Down Expand Up @@ -94,19 +100,21 @@ func (q *TopologyQueryBuilder) Build() string {
// topk(
// <k>,
// sum by(<aggregations>) (
// rate(
// <function>(
// {<label filters>}|<line filters>|json|<json filters>
// |unwrap Bytes|__error__=""[300s]
// |unwrap Bytes|__error__=""[<interval>]
// )
// )
// )
// &<query params>&step=300s
// &<query params>&step=<step>
sb := q.createStringBuilderURL()
sb.WriteString("topk(")
sb.WriteString(q.topology.limit)
sb.WriteString(",sum by(")
sb.WriteString(q.topology.fields)
sb.WriteString(") (rate(")
sb.WriteString(") (")
sb.WriteString(q.topology.function)
sb.WriteString("(")
q.appendLabels(sb)
q.appendLineFilters(sb)
q.appendDeduplicateFilter(sb)
Expand All @@ -117,7 +125,11 @@ func (q *TopologyQueryBuilder) Build() string {
sb.WriteString(`|__error__=""`)
}
sb.WriteRune('[')
sb.WriteString(q.topology.rateInterval)
if q.topology.function == "count_over_time" {
sb.WriteString(q.topology.step)
} else {
sb.WriteString(q.topology.rateInterval)
}
sb.WriteString("])))")
q.appendQueryParams(sb)
sb.WriteString("&step=")
Expand Down
40 changes: 40 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,46 @@ func TestLokiConfigurationForTopology(t *testing.T) {
assert.NotNil(t, qr.Result)
}

func TestLokiConfigurationForTableHistogram(t *testing.T) {
// GIVEN a Loki service
lokiMock := httpMock{}
lokiMock.On("ServeHTTP", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
_, _ = args.Get(0).(http.ResponseWriter).Write([]byte(`{"status":"","data":{"resultType":"matrix","result":[]}}`))
})
lokiSvc := httptest.NewServer(&lokiMock)
defer lokiSvc.Close()
lokiURL, err := url.Parse(lokiSvc.URL)
require.NoError(t, err)

// THAT is accessed behind the NOO console plugin backend
backendRoutes := setupRoutes(&Config{
Loki: loki.Config{
URL: lokiURL,
Timeout: time.Second,
},
})
backendSvc := httptest.NewServer(backendRoutes)
defer backendSvc.Close()

// WHEN the Loki flows endpoint is queried in the backend using count type
resp, err := backendSvc.Client().Get(backendSvc.URL + "/api/loki/topology?type=count")
require.NoError(t, err)

// THEN the query has been properly forwarded to Loki
req := lokiMock.Calls[0].Arguments[1].(*http.Request)
assert.Equal(t, `topk(100,sum by(SrcK8S_Name,SrcK8S_Type,SrcK8S_OwnerName,SrcK8S_OwnerType,SrcK8S_Namespace,SrcAddr,SrcK8S_HostName,DstK8S_Name,DstK8S_Type,DstK8S_OwnerName,DstK8S_OwnerType,DstK8S_Namespace,DstAddr,DstK8S_HostName) (count_over_time({app="netobserv-flowcollector"}|~`+"`"+`Duplicate":false`+"`"+`|json[30s])))`, req.URL.Query().Get("query"))
// without any multi-tenancy header
assert.Empty(t, req.Header.Get("X-Scope-OrgID"))

// AND the response is sent back to the client
body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
var qr model.AggregatedQueryResponse
err = json.Unmarshal(body, &qr)
require.NoError(t, err)
assert.NotNil(t, qr.Result)
}

func TestLokiConfiguration_MultiTenant(t *testing.T) {
lokiMock := httpMock{}
lokiMock.On("ServeHTTP", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
Expand Down
3 changes: 3 additions & 0 deletions web/locales/en/plugin__netobserv-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"Show configuration limits": "Show configuration limits",
"Copy": "Copy",
"Copied": "Copied",
"No datapoints found.": "No datapoints found.",
"(non nodes)": "(non nodes)",
"(non pods)": "(non pods)",
"internal": "internal",
Expand Down Expand Up @@ -208,6 +209,8 @@
"Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance": "Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance",
"Add more filters or decrease limit / range to improve the query performance": "Add more filters or decrease limit / range to improve the query performance",
"Network Traffic": "Network Traffic",
"Hide histogram": "Hide histogram",
"Show histogram": "Show histogram",
"Hide advanced options": "Hide advanced options",
"Show advanced options": "Show advanced options",
"Filtered sum of bytes": "Filtered sum of bytes",
Expand Down
4 changes: 2 additions & 2 deletions web/src/api/loki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ export interface StreamResult {
values: string[][];
}

export interface RecordsResult {
export class RecordsResult {
records: Record[];
stats: Stats;
}

export interface TopologyResult {
export class TopologyResult {
metrics: TopologyMetrics[];
stats: Stats;
}
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/dropdowns/metric-type-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const MetricTypeDropdown: React.FC<{
return t('Packets');
case 'bytes':
return t('Bytes');
default:
throw new Error('getMetricDisplay called with invalid metricType: ' + metricType);
}
},
[t]
Expand Down
27 changes: 27 additions & 0 deletions web/src/components/metrics/brush-handle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';

export const BrushHandleComponent: React.FC<{
x?: number;
y?: number;
width?: number;
height?: number;
isDark?: boolean;
}> = ({ x, y, width, height, isDark }) => {
if (x === undefined || y === undefined || width === undefined || height === undefined) {
return null;
}

const triangleSize = 6;
const color = isDark ? '#D2D2D2' : '#0066CC';
return (
<g>
<line x1={x} x2={x} y1={y} y2={height + y} style={{ stroke: color, strokeDasharray: '5 3' }} />
<polygon
points={`${x},${y} ${x - triangleSize},${y - triangleSize} ${x + triangleSize},${y - triangleSize}`}
style={{ fill: color }}
/>
</g>
);
};

export default BrushHandleComponent;
7 changes: 7 additions & 0 deletions web/src/components/metrics/histogram.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.histogram-range {
position: absolute;
left: 0;
right: 0;
text-align: center;
margin: 5px;
}
141 changes: 141 additions & 0 deletions web/src/components/metrics/histogram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Chart, ChartAxis, ChartBar, ChartStack, ChartThemeColor, createContainer } from '@patternfly/react-charts';
import { Bullseye, EmptyStateBody, Spinner, Text } from '@patternfly/react-core';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { NamedMetric, TopologyMetrics } from '../../api/loki';
import { TimeRange } from '../../utils/datetime';
import { getDateMsInSeconds } from '../../utils/duration';
import { getFormattedRateValue } from '../../utils/metrics';
import { TruncateLength } from '../dropdowns/truncate-dropdown';
import BrushHandleComponent from './brush-handle';
import {
ChartDataPoint,
Dimensions,
getDomainDisplayText,
getDomainFromRange,
getHistogramRangeFromLimit,
observe,
toHistogramDatapoints,
toNamedMetric
} from './metrics-helper';
import './histogram.css';

export const VoronoiContainer = createContainer('voronoi', 'brush');

export const Histogram: React.FC<{
id: string;
totalMetric: NamedMetric;
limit: number;
isDark: boolean;
range?: TimeRange;
setRange: (tr: TimeRange) => void;
}> = ({ id, totalMetric, limit, isDark, range, setRange }) => {
const datapoints: ChartDataPoint[] = toHistogramDatapoints(totalMetric);
const defaultRange = getHistogramRangeFromLimit(totalMetric, limit);

const containerRef = React.createRef<HTMLDivElement>();
const [dimensions, setDimensions] = React.useState<Dimensions>({ width: 3000, height: 300 });
React.useEffect(() => {
observe(containerRef, dimensions, setDimensions);
}, [containerRef, dimensions]);

return (
<div id={`chart-${id}`} className="metrics-content-div" ref={containerRef}>
<Text className="histogram-range">{getDomainDisplayText(range ? range : defaultRange)}</Text>
<Chart
themeColor={ChartThemeColor.multiUnordered}
containerComponent={
<VoronoiContainer
brushDimension="x"
onBrushDomainChange={(updated?: { x?: Array<Date | number> }) => {
if (limit && updated?.x && updated.x.length && typeof updated.x[0] === 'object') {
const start = getDateMsInSeconds(updated.x[0].getTime());
const range = getHistogramRangeFromLimit(totalMetric, limit, start);

if (range.from < range.to) {
updated.x = getDomainFromRange(range);
}
}
}}
onBrushDomainChangeEnd={(domain?: { x?: Array<Date | number> }) => {
if (
domain?.x &&
domain.x.length > 1 &&
typeof domain.x[0] === 'object' &&
typeof domain.x[1] === 'object'
) {
const start = domain.x[0];
const end = domain.x[1];

if (start.getTime() < end.getTime()) {
setRange({ from: getDateMsInSeconds(start.getTime()), to: getDateMsInSeconds(end.getTime()) });
}
}
}}
handleComponent={<BrushHandleComponent isDark={isDark} />}
defaultBrushArea="none"
handleWidth={1}
brushStyle={{ stroke: 'transparent', fill: 'black', fillOpacity: 0.1 }}
brushDomain={{
x: getDomainFromRange(range ? range : defaultRange)
}}
/>
}
//TODO: fix refresh on selection change to enable animation
//animate={true}
scale={{ x: 'time', y: 'linear' }}
width={dimensions.width}
height={dimensions.height}
padding={{
top: 30,
right: 10,
bottom: 35,
left: 60
}}
>
<ChartAxis fixLabelOverlap />
<ChartAxis dependentAxis showGrid fixLabelOverlap tickFormat={y => getFormattedRateValue(y, 'count')} />
<ChartStack>
<ChartBar
name={`bar-${id}`}
key={`bar-${id}`}
data={datapoints}
barWidth={(dimensions.width / datapoints.length) * 0.8}
alignment={'start'}
/>
</ChartStack>
</Chart>
</div>
);
};

export const HistogramContainer: React.FC<{
id: string;
loading: boolean;
totalMetric: TopologyMetrics | undefined;
limit: number;
isDark: boolean;
range?: TimeRange;
setRange: (tr: TimeRange) => void;
}> = ({ id, loading, totalMetric, limit, isDark, range, setRange }) => {
const { t } = useTranslation('plugin__netobserv-plugin');

return totalMetric ? (
<Histogram
id={id}
totalMetric={toNamedMetric(t, totalMetric, TruncateLength.OFF, false, false)}
limit={limit}
isDark={isDark}
range={range}
setRange={setRange}
/>
) : loading ? (
<Bullseye data-test="loading-histogram">
<Spinner size="xl" />
</Bullseye>
) : (
<EmptyStateBody>{t('No datapoints found.')}</EmptyStateBody>
);
};

export default HistogramContainer;
Loading