feat: configure limit type (#1482)

* Add limit type field selection

* Take into account LimitType to generate SQL request for Line graph

Also, add LimitType in default configuration for console

* Take into account LimitType to generate SQL request for Sankey graph

* Refactor on SQL query used by line and sankey

* Add limitType description in doc

* Order by max in graphLine when limitType max is used

* Fix query when using top max

Revert some modifications, as they were no longer relevant with the query fixed.

* Rework way to sort by max in line graph type

* Add configuration validation on LimitType

---------

Co-authored-by: Dimitri Baudrier <github.52grm@simplelogin.com>
This commit is contained in:
dimbdr
2024-11-23 18:39:28 +01:00
committed by GitHub
parent 37226280a0
commit 6c0e8e1791
13 changed files with 929 additions and 378 deletions

View File

@@ -45,6 +45,8 @@ type VisualizeOptionsConfiguration struct {
Dimensions []query.Column `json:"dimensions"` Dimensions []query.Column `json:"dimensions"`
// Limit is the default limit to use // Limit is the default limit to use
Limit int `json:"limit" validate:"min=5"` Limit int `json:"limit" validate:"min=5"`
// LimitType is the default limitType to use
LimitType string `json:"limitType" validate:"oneof=Avg Max"`
// Bidirectional tells if a graph should be bidirectional (all except sankey) // Bidirectional tells if a graph should be bidirectional (all except sankey)
Bidirectional bool `json:"bidirectional"` Bidirectional bool `json:"bidirectional"`
// PreviousPeriod tells if a graph should display the previous period (for stacked) // PreviousPeriod tells if a graph should display the previous period (for stacked)
@@ -61,6 +63,7 @@ func DefaultConfiguration() Configuration {
Filter: "InIfBoundary = external", Filter: "InIfBoundary = external",
Dimensions: []query.Column{query.NewColumn("SrcAS")}, Dimensions: []query.Column{query.NewColumn("SrcAS")},
Limit: 10, Limit: 10,
LimitType: "Avg",
}, },
HomepageTopWidgets: []string{"src-as", "src-port", "protocol", "src-country", "etype"}, HomepageTopWidgets: []string{"src-as", "src-port", "protocol", "src-country", "etype"},
DimensionsLimit: 50, DimensionsLimit: 50,

View File

@@ -26,6 +26,7 @@ func TestConfigHandler(t *testing.T) {
"filter": "InIfBoundary = external", "filter": "InIfBoundary = external",
"dimensions": []string{"SrcAS"}, "dimensions": []string{"SrcAS"},
"limit": 10, "limit": 10,
"limitType": "Avg",
"bidirectional": false, "bidirectional": false,
"previousPeriod": false, "previousPeriod": false,
}, },

View File

@@ -913,7 +913,7 @@ The console itself accepts the following keys:
- `default-visualize-options` to define default options for the "visualize" - `default-visualize-options` to define default options for the "visualize"
tab. It takes the following keys: `graph-type` (one of `stacked`, tab. It takes the following keys: `graph-type` (one of `stacked`,
`stacked100`, `lines`, `grid`, or `sankey`), `start`, `end`, `filter`, `stacked100`, `lines`, `grid`, or `sankey`), `start`, `end`, `filter`,
`dimensions` (a list), `limit`, `bidirectional` (a bool), `previous-period` `dimensions` (a list), `limit`, `limitType`, `bidirectional` (a bool), `previous-period`
(a bool) (a bool)
- `homepage-top-widgets` to define the widgets to display on the home page - `homepage-top-widgets` to define the widgets to display on the home page
(among `src-as`, `dst-as`, `src-country`, `dst-country`, `exporter`, (among `src-as`, `dst-as`, `src-country`, `dst-country`, `exporter`,

View File

@@ -151,6 +151,10 @@ aspect of the graph.
"limit" parameter tells how many. The remaining values are "limit" parameter tells how many. The remaining values are
categorized as "Other". categorized as "Other".
- Associated with the `limit` parameter, the `limitType` parameter help find traffic surges according to 2 modes:
- Avg: default mode, the query focuses on getting the highest cumulative traffics over the time selection.
- Max: the query focuses on getting the traffic bursts over the time selection.
- The filter box contains an SQL-like expression to limit the data to be - The filter box contains an SQL-like expression to limit the data to be
graphed. It features an auto-completion system that can be triggered manually graphed. It features an auto-completion system that can be triggered manually
with `Ctrl-Space`. `Ctrl-Enter` executes the request. Filters can be saved by with `Ctrl-Space`. `Ctrl-Enter` executes the request. Filters can be saved by

View File

@@ -61,12 +61,27 @@
label="IPv6 /x" label="IPv6 /x"
:error="truncate6Error" :error="truncate6Error"
/> />
</div>
<div class="flex flex-row flex-nowrap gap-2">
<InputString <InputString
v-model="limit" v-model="limit"
class="grow" class="grow"
label="Limit" label="Limit"
:error="limitError" :error="limitError"
/> />
<InputListBox
v-model="limitType"
:items="computationModeList"
class="order-3 grow basis-full sm:max-lg:order-3 sm:max-lg:basis-0"
label="Top by"
>
<template #selected>{{ limitType.name }}</template>
<template #item="{ name }">
<div class="flex w-full items-center justify-between">
<span>{{ name }}</span>
</div>
</template>
</InputListBox>
</div> </div>
</div> </div>
</template> </template>
@@ -117,6 +132,21 @@ const limitError = computed(() => {
} }
return ""; return "";
}); });
const computationModes = {
avg: "Avg",
max: "Max",
} as const;
const computationModeList = Object.entries(computationModes).map(
([k, v], idx) => ({
id: idx + 1,
type: k as keyof typeof computationModes, // why isn't it infered?
name: v,
}),
);
const limitType = ref(computationModeList[0]);
const canAggregate = computed( const canAggregate = computed(
() => () =>
intersection( intersection(
@@ -147,7 +177,6 @@ const hasErrors = computed(
!!truncate4Error.value || !!truncate4Error.value ||
!!truncate6Error.value, !!truncate6Error.value,
); );
const dimensions = computed( const dimensions = computed(
() => () =>
serverConfiguration.value?.dimensions.map((v, idx) => ({ serverConfiguration.value?.dimensions.map((v, idx) => ({
@@ -166,12 +195,14 @@ const removeDimension = (dimension: (typeof dimensions.value)[0]) => {
(d) => d !== dimension, (d) => d !== dimension,
); );
}; };
watch( watch(
() => [props.modelValue, dimensions.value] as const, () => [props.modelValue, dimensions.value] as const,
([value, dimensions]) => { ([value, dimensions]) => {
if (value) { if (value) {
limit.value = value.limit.toString(); limit.value = value.limit.toString();
limitType.value =
computationModeList.find((mode) => mode.name === value.limitType) ||
computationModeList[0];
truncate4.value = value.truncate4.toString(); truncate4.value = value.truncate4.toString();
truncate6.value = value.truncate6.toString(); truncate6.value = value.truncate6.toString();
} }
@@ -183,11 +214,19 @@ watch(
{ immediate: true, deep: true }, { immediate: true, deep: true },
); );
watch( watch(
[selectedDimensions, limit, truncate4, truncate6, hasErrors] as const, [
([selected, limit, truncate4, truncate6, hasErrors]) => { selectedDimensions,
limit,
limitType,
truncate4,
truncate6,
hasErrors,
] as const,
([selected, limit, limitType, truncate4, truncate6, hasErrors]) => {
const updated = { const updated = {
selected: selected.map((d) => d.name), selected: selected.map((d) => d.name),
limit: parseInt(limit), limit: parseInt(limit),
limitType: limitType.name,
truncate4: parseInt(truncate4), truncate4: parseInt(truncate4),
truncate6: parseInt(truncate6), truncate6: parseInt(truncate6),
errors: hasErrors, errors: hasErrors,
@@ -208,6 +247,7 @@ watch(
export type ModelType = { export type ModelType = {
selected: string[]; selected: string[];
limit: number; limit: number;
limitType: string;
truncate4: number; truncate4: number;
truncate6: number; truncate6: number;
errors?: boolean; errors?: boolean;

View File

@@ -29,6 +29,7 @@ type ServerConfig = {
filter: string; filter: string;
dimensions: string[]; dimensions: string[];
limit: number; limit: number;
limitType: string;
bidirectional: boolean; bidirectional: boolean;
previousPeriod: boolean; previousPeriod: boolean;
}; };

View File

@@ -192,6 +192,7 @@ const options = computed((): InternalModelType => {
humanEnd: timeRange.value?.end, humanEnd: timeRange.value?.end,
dimensions: dimensions.value?.selected, dimensions: dimensions.value?.selected,
limit: dimensions.value?.limit, limit: dimensions.value?.limit,
limitType: dimensions.value?.limitType,
"truncate-v4": dimensions.value?.truncate4, "truncate-v4": dimensions.value?.truncate4,
"truncate-v6": dimensions.value?.truncate6, "truncate-v6": dimensions.value?.truncate6,
filter: filter.value?.expression, filter: filter.value?.expression,
@@ -243,6 +244,7 @@ watch(
humanEnd: defaultOptions.end, humanEnd: defaultOptions.end,
dimensions: toRaw(defaultOptions.dimensions), dimensions: toRaw(defaultOptions.dimensions),
limit: defaultOptions.limit, limit: defaultOptions.limit,
limitType: defaultOptions.limitType,
"truncate-v4": 32, "truncate-v4": 32,
"truncate-v6": 128, "truncate-v6": 128,
filter: defaultOptions.filter, filter: defaultOptions.filter,
@@ -262,6 +264,7 @@ watch(
dimensions.value = { dimensions.value = {
selected: [...currentValue.dimensions], selected: [...currentValue.dimensions],
limit: currentValue.limit, limit: currentValue.limit,
limitType: currentValue.limitType,
truncate4: currentValue["truncate-v4"] || 32, truncate4: currentValue["truncate-v4"] || 32,
truncate6: currentValue["truncate-v6"] || 128, truncate6: currentValue["truncate-v6"] || 128,
}; };
@@ -296,6 +299,7 @@ export type ModelType = {
humanEnd: string; humanEnd: string;
dimensions: string[]; dimensions: string[];
limit: number; limit: number;
limitType: string;
"truncate-v4": number; "truncate-v4": number;
"truncate-v6": number; "truncate-v6": number;
filter: string; filter: string;

View File

@@ -18,8 +18,9 @@ type graphCommonHandlerInput struct {
schema *schema.Component schema *schema.Component
Start time.Time `json:"start" binding:"required"` Start time.Time `json:"start" binding:"required"`
End time.Time `json:"end" binding:"required,gtfield=Start"` End time.Time `json:"end" binding:"required,gtfield=Start"`
Dimensions []query.Column `json:"dimensions"` // group by ... Dimensions []query.Column `json:"dimensions"` // group by ...
Limit int `json:"limit" binding:"min=1"` // limit product of dimensions Limit int `json:"limit" binding:"min=1"` // limit product of dimensions
LimitType string `json:"limitType" validate:"oneof=Avg Max"`
Filter query.Filter `json:"filter"` // where ... Filter query.Filter `json:"filter"` // where ...
TruncateAddrV4 int `json:"truncate-v4" binding:"min=0,max=32"` // 0 or 32 = no truncation TruncateAddrV4 int `json:"truncate-v4" binding:"min=0,max=32"` // 0 or 32 = no truncation
TruncateAddrV6 int `json:"truncate-v6" binding:"min=0,max=128"` // 0 or 128 = no truncation TruncateAddrV6 int `json:"truncate-v6" binding:"min=0,max=128"` // 0 or 128 = no truncation

View File

@@ -139,12 +139,7 @@ func (input graphLineHandlerInput) toSQL1(axis int, options toSQL1Options) strin
if !options.skipWithClause { if !options.skipWithClause {
with := []string{fmt.Sprintf("source AS (%s)", input.sourceSelect())} with := []string{fmt.Sprintf("source AS (%s)", input.sourceSelect())}
if len(dimensions) > 0 { if len(dimensions) > 0 {
with = append(with, fmt.Sprintf( with = append(with, selectLineRowsByLimitType(input, dimensions, where))
"rows AS (SELECT %s FROM source WHERE %s GROUP BY %s ORDER BY {{ .Units }} DESC LIMIT %d)",
strings.Join(dimensions, ", "),
where,
strings.Join(dimensions, ", "),
input.Limit))
} }
if len(with) > 0 { if len(with) > 0 {
withStr = fmt.Sprintf("\nWITH\n %s", strings.Join(with, ",\n ")) withStr = fmt.Sprintf("\nWITH\n %s", strings.Join(with, ",\n "))
@@ -289,6 +284,7 @@ func (c *Component) graphLineHandlerFunc(gc *gin.Context) {
rows := map[int]map[string][]string{} // for each axis, a map from row to list of dimensions rows := map[int]map[string][]string{} // for each axis, a map from row to list of dimensions
points := map[int]map[string][]int{} // for each axis, a map from row to list of points (one point per ts) points := map[int]map[string][]int{} // for each axis, a map from row to list of points (one point per ts)
sums := map[int]map[string]uint64{} // for each axis, a map from row to sum (for sorting purpose) sums := map[int]map[string]uint64{} // for each axis, a map from row to sum (for sorting purpose)
maxes := map[int]map[string]uint64{} // for each axis, a map from row to max (for sorting purpose)
lastTimeForAxis := map[int]time.Time{} lastTimeForAxis := map[int]time.Time{}
timeIndexForAxis := map[int]int{} timeIndexForAxis := map[int]int{}
for _, result := range results { for _, result := range results {
@@ -303,6 +299,7 @@ func (c *Component) graphLineHandlerFunc(gc *gin.Context) {
rows[axis] = map[string][]string{} rows[axis] = map[string][]string{}
points[axis] = map[string][]int{} points[axis] = map[string][]int{}
sums[axis] = map[string]uint64{} sums[axis] = map[string]uint64{}
maxes[axis] = map[string]uint64{}
} }
if result.Time != lastTime { if result.Time != lastTime {
// New timestamp, increment time index // New timestamp, increment time index
@@ -317,9 +314,13 @@ func (c *Component) graphLineHandlerFunc(gc *gin.Context) {
row := make([]int, len(output.Time)) row := make([]int, len(output.Time))
points[axis][rowKey] = row points[axis][rowKey] = row
sums[axis][rowKey] = 0 sums[axis][rowKey] = 0
maxes[axis][rowKey] = 0
} }
points[axis][rowKey][timeIndexForAxis[axis]] = int(result.Xps) points[axis][rowKey][timeIndexForAxis[axis]] = int(result.Xps)
sums[axis][rowKey] += uint64(result.Xps) sums[axis][rowKey] += uint64(result.Xps)
if uint64(result.Xps) > maxes[axis][rowKey] {
maxes[axis][rowKey] = uint64(result.Xps)
}
} }
// Sort axes // Sort axes
sort.Ints(axes) sort.Ints(axes)
@@ -339,6 +340,10 @@ func (c *Component) graphLineHandlerFunc(gc *gin.Context) {
if rows[axis][jKey][0] == "Other" { if rows[axis][jKey][0] == "Other" {
return true return true
} }
if input.LimitType == "Max" {
return maxes[axis][iKey] > maxes[axis][jKey]
}
return sums[axis][iKey] > sums[axis][jKey] return sums[axis][iKey] > sums[axis][jKey]
}) })
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
package console package console
import ( import (
"fmt"
"strings" "strings"
"akvorado/common/schema" "akvorado/common/schema"
@@ -32,3 +33,36 @@ func (c *Component) fixQueryColumnName(name string) string {
} }
return "" return ""
} }
func selectSankeyRowsByLimitType(input graphSankeyHandlerInput, dimensions []string, where string) string {
return selectRowsByLimitType(input.graphCommonHandlerInput, dimensions, where)
}
func selectLineRowsByLimitType(input graphLineHandlerInput, dimensions []string, where string) string {
return selectRowsByLimitType(input.graphCommonHandlerInput, dimensions, where)
}
func selectRowsByLimitType(input graphCommonHandlerInput, dimensions []string, where string) string {
var rowsType string
var source string
var orderBy string
if input.LimitType == "Max" {
source = fmt.Sprintf("( SELECT %s AS sum_at_time FROM source WHERE %s GROUP BY %s )",
strings.Join(append(dimensions, "{{ .Units }}"), ", "),
where,
strings.Join(dimensions, ", "),
)
orderBy = "MAX(sum_at_time)"
} else {
source = fmt.Sprintf("source WHERE %s", where)
orderBy = "{{ .Units }}"
}
rowsType = fmt.Sprintf(
"rows AS (SELECT %s FROM %s GROUP BY %s ORDER BY %s DESC LIMIT %d)",
strings.Join(dimensions, ", "),
source,
strings.Join(dimensions, ", "),
orderBy,
input.Limit)
return rowsType
}

View File

@@ -57,14 +57,8 @@ func (input graphSankeyHandlerInput) toSQL() (string, error) {
// With // With
with := []string{ with := []string{
fmt.Sprintf("source AS (%s)", input.sourceSelect()), fmt.Sprintf("source AS (%s)", input.sourceSelect()),
fmt.Sprintf(`(SELECT MAX(TimeReceived) - MIN(TimeReceived) FROM source WHERE %s) AS range`, where), fmt.Sprintf(`(SELECT MAX(TimeReceived) - MIN(TimeReceived) FROM source WHERE %s) AS range`, where)}
fmt.Sprintf( with = append(with, selectSankeyRowsByLimitType(input, dimensions, where))
"rows AS (SELECT %s FROM source WHERE %s GROUP BY %s ORDER BY {{ .Units }} DESC LIMIT %d)",
strings.Join(dimensions, ", "),
where,
strings.Join(dimensions, ", "),
input.Limit),
}
sqlQuery := fmt.Sprintf(` sqlQuery := fmt.Sprintf(`
{{ with %s }} {{ with %s }}

View File

@@ -53,6 +53,38 @@ FROM source
WHERE {{ .Timefilter }} WHERE {{ .Timefilter }}
GROUP BY dimensions GROUP BY dimensions
ORDER BY xps DESC ORDER BY xps DESC
{{ end }}`,
}, {
Description: "two dimensions, no filters, l3 bps, limitType by max",
Pos: helpers.Mark(),
Input: graphSankeyHandlerInput{
graphCommonHandlerInput{
Start: time.Date(2022, 4, 10, 15, 45, 10, 0, time.UTC),
End: time.Date(2022, 4, 11, 15, 45, 10, 0, time.UTC),
Dimensions: []query.Column{
query.NewColumn("SrcAS"),
query.NewColumn("ExporterName"),
},
Limit: 5,
LimitType: "Max",
Filter: query.Filter{},
Units: "l3bps",
},
},
Expected: `
{{ with context @@{"start":"2022-04-10T15:45:10Z","end":"2022-04-11T15:45:10Z","points":20,"units":"l3bps"}@@ }}
WITH
source AS (SELECT * FROM {{ .Table }} SETTINGS asterisk_include_alias_columns = 1),
(SELECT MAX(TimeReceived) - MIN(TimeReceived) FROM source WHERE {{ .Timefilter }}) AS range,
rows AS (SELECT SrcAS, ExporterName FROM ( SELECT SrcAS, ExporterName, {{ .Units }} AS sum_at_time FROM source WHERE {{ .Timefilter }} GROUP BY SrcAS, ExporterName ) GROUP BY SrcAS, ExporterName ORDER BY MAX(sum_at_time) DESC LIMIT 5)
SELECT
{{ .Units }}/range AS xps,
[if(SrcAS IN (SELECT SrcAS FROM rows), concat(toString(SrcAS), ': ', dictGetOrDefault('asns', 'name', SrcAS, '???')), 'Other'),
if(ExporterName IN (SELECT ExporterName FROM rows), ExporterName, 'Other')] AS dimensions
FROM source
WHERE {{ .Timefilter }}
GROUP BY dimensions
ORDER BY xps DESC
{{ end }}`, {{ end }}`,
}, { }, {
Description: "two dimensions, no filters, l2 bps", Description: "two dimensions, no filters, l2 bps",