mirror of
https://github.com/akvorado/akvorado.git
synced 2025-12-11 22:14:02 +01:00
Some checks failed
CI / 🤖 Check dependabot status (push) Has been cancelled
CI / 🐧 Test on Linux (${{ github.ref_type == 'tag' }}, misc) (push) Has been cancelled
CI / 🐧 Test on Linux (coverage) (push) Has been cancelled
CI / 🐧 Test on Linux (regular) (push) Has been cancelled
CI / ❄️ Build on Nix (push) Has been cancelled
CI / 🍏 Build and test on macOS (push) Has been cancelled
CI / 🧪 End-to-end testing (push) Has been cancelled
CI / 🔍 Upload code coverage (push) Has been cancelled
CI / 🔬 Test only Go (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 20) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 22) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 24) (push) Has been cancelled
CI / ⚖️ Check licenses (push) Has been cancelled
CI / 🐋 Build Docker images (push) Has been cancelled
CI / 🐋 Tag Docker images (push) Has been cancelled
CI / 🚀 Publish release (push) Has been cancelled
Update Nix dependency hashes / Update dependency hashes (push) Has been cancelled
* fix index 95th percentile * go fmt, update expected 95th percentile test results
481 lines
15 KiB
Go
481 lines
15 KiB
Go
// SPDX-FileCopyrightText: 2022 Free Mobile
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package console
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"akvorado/common/helpers"
|
|
"akvorado/console/query"
|
|
)
|
|
|
|
// graphLineHandlerInput describes the input for the /graph/line endpoint.
|
|
type graphLineHandlerInput struct {
|
|
graphCommonHandlerInput
|
|
Points uint `json:"points" binding:"required,min=5,max=2000"` // minimum number of points
|
|
Bidirectional bool `json:"bidirectional"`
|
|
PreviousPeriod bool `json:"previous-period"`
|
|
}
|
|
|
|
// graphLineHandlerOutput describes the output for the /graph/line endpoint. A
|
|
// row is a set of values for dimensions. Currently, axis 1 is for the
|
|
// direct direction and axis 2 is for the reverse direction. Rows are
|
|
// sorted by axis, then by the sum of traffic.
|
|
type graphLineHandlerOutput struct {
|
|
Time []time.Time `json:"t"`
|
|
Rows [][]string `json:"rows"` // List of rows
|
|
Points [][]int `json:"points"` // t → row → xps
|
|
Axis []int `json:"axis"` // row → axis
|
|
AxisNames map[int]string `json:"axis-names"`
|
|
Average []int `json:"average"` // row → average xps
|
|
Min []int `json:"min"` // row → min xps
|
|
Max []int `json:"max"` // row → max xps
|
|
Last []int `json:"last"` // row → last xps
|
|
NinetyFivePercentile []int `json:"95th"` // row → 95th xps
|
|
}
|
|
|
|
// reverseDirection reverts the direction of a provided input. It does not
|
|
// modify the original.
|
|
func (input graphLineHandlerInput) reverseDirection() graphLineHandlerInput {
|
|
input.Filter.Swap()
|
|
input.Dimensions = slices.Clone(input.Dimensions)
|
|
query.Columns(input.Dimensions).Reverse(input.schema)
|
|
return input
|
|
}
|
|
|
|
// nearestPeriod returns the name and period matching the provided
|
|
// period length. The year is a special case as we don't know its
|
|
// exact length.
|
|
func nearestPeriod(period time.Duration) (time.Duration, string) {
|
|
switch {
|
|
case period < 2*time.Hour:
|
|
return time.Hour, "hour"
|
|
case period < 2*24*time.Hour:
|
|
return 24 * time.Hour, "day"
|
|
case period < 2*7*24*time.Hour:
|
|
return 7 * 24 * time.Hour, "week"
|
|
case period < 2*4*7*24*time.Hour:
|
|
// We use 4 weeks, not 1 month
|
|
return 4 * 7 * 24 * time.Hour, "month"
|
|
default:
|
|
return 0, "year"
|
|
}
|
|
}
|
|
|
|
// previousPeriod shifts the provided input to the previous period.
|
|
// The chosen period depend on the current period. For less than
|
|
// 2-hour period, the previous period is the hour. For less than 2-day
|
|
// period, this is the day. For less than 2-weeks, this is the week,
|
|
// for less than 2-months, this is the month, otherwise, this is the
|
|
// year. Also, dimensions are stripped.
|
|
func (input graphLineHandlerInput) previousPeriod() graphLineHandlerInput {
|
|
input.Dimensions = []query.Column{}
|
|
diff := input.End.Sub(input.Start)
|
|
period, _ := nearestPeriod(diff)
|
|
if period == 0 {
|
|
// We use a full year this time (think for example we
|
|
// want to see how was New Year Eve compared to last
|
|
// year)
|
|
input.Start = input.Start.AddDate(-1, 0, 0)
|
|
input.End = input.End.AddDate(-1, 0, 0)
|
|
return input
|
|
}
|
|
input.Start = input.Start.Add(-period)
|
|
input.End = input.End.Add(-period)
|
|
return input
|
|
}
|
|
|
|
type toSQL1Options struct {
|
|
skipWithClause bool
|
|
reverseDirection bool
|
|
offsetedStart time.Time
|
|
mainTableRequired bool
|
|
}
|
|
|
|
func (input graphLineHandlerInput) toSQL1(axis int, options toSQL1Options) templateQuery {
|
|
var startForInterval *time.Time
|
|
var offsetShift string
|
|
if !options.offsetedStart.IsZero() {
|
|
startForInterval = &options.offsetedStart
|
|
offsetShift = fmt.Sprintf(" + INTERVAL %d second",
|
|
int64(options.offsetedStart.Sub(input.Start).Seconds()))
|
|
}
|
|
where := templateWhere(input.Filter)
|
|
|
|
// Select
|
|
fields := []string{
|
|
fmt.Sprintf(`{{ call .ToStartOfInterval "TimeReceived" }}%s AS time`, offsetShift),
|
|
`{{ .Units }}/{{ .Interval }} AS xps`,
|
|
}
|
|
selectFields := []string{}
|
|
dimensions := []string{}
|
|
dimensionsInterpolate := ""
|
|
others := []string{}
|
|
for _, column := range input.Dimensions {
|
|
field := column.ToSQLSelect(input.schema)
|
|
selectFields = append(selectFields, field)
|
|
dimensions = append(dimensions, column.String())
|
|
others = append(others, "'Other'")
|
|
}
|
|
if len(dimensions) > 0 {
|
|
fields = append(fields, fmt.Sprintf(`if((%s) IN rows, [%s], [%s]) AS dimensions`,
|
|
strings.Join(dimensions, ", "),
|
|
strings.Join(selectFields, ", "),
|
|
strings.Join(others, ", ")))
|
|
dimensionsInterpolate = fmt.Sprintf("[%s]", strings.Join(others, ", "))
|
|
} else {
|
|
fields = append(fields, "emptyArrayString() AS dimensions")
|
|
dimensionsInterpolate = "emptyArrayString()"
|
|
}
|
|
|
|
// With
|
|
withStr := ""
|
|
if !options.skipWithClause {
|
|
with := []string{fmt.Sprintf("source AS (%s)", input.sourceSelect())}
|
|
if len(dimensions) > 0 {
|
|
with = append(with, selectLineRowsByLimitType(input, dimensions, where))
|
|
}
|
|
if len(with) > 0 {
|
|
withStr = fmt.Sprintf("\nWITH\n %s", strings.Join(with, ",\n "))
|
|
}
|
|
}
|
|
|
|
// Units
|
|
units := input.Units
|
|
if options.reverseDirection {
|
|
switch units {
|
|
case "inl2%":
|
|
units = "outl2%"
|
|
case "outl2%":
|
|
units = "inl2%"
|
|
}
|
|
}
|
|
|
|
template := fmt.Sprintf(`%s
|
|
SELECT %d AS axis, * FROM (
|
|
SELECT
|
|
%s
|
|
FROM source
|
|
WHERE %s
|
|
GROUP BY time, dimensions
|
|
ORDER BY time WITH FILL
|
|
FROM {{ .TimefilterStart }}%s
|
|
TO {{ .TimefilterEnd }} + INTERVAL 1 second%s
|
|
STEP {{ .Interval }}
|
|
INTERPOLATE (dimensions AS %s))`,
|
|
withStr, axis, strings.Join(fields, ",\n "), where, offsetShift, offsetShift,
|
|
dimensionsInterpolate,
|
|
)
|
|
|
|
context := inputContext{
|
|
Start: input.Start,
|
|
End: input.End,
|
|
StartForTableSelection: startForInterval,
|
|
MainTableRequired: options.mainTableRequired,
|
|
Points: input.Points,
|
|
Units: units,
|
|
}
|
|
|
|
return templateQuery{
|
|
Template: strings.TrimSpace(template),
|
|
Context: context,
|
|
}
|
|
}
|
|
|
|
// toSQL converts a graph input to an SQL request
|
|
func (input graphLineHandlerInput) toSQL() []templateQuery {
|
|
// Calculate mainTableRequired once and use it for all axes to ensure
|
|
// consistency. This is useful as previous period will remove the
|
|
// dimensions.
|
|
mainTableRequired := requireMainTable(input.schema, input.Dimensions, input.Filter)
|
|
queries := []templateQuery{input.toSQL1(1, toSQL1Options{
|
|
mainTableRequired: mainTableRequired,
|
|
})}
|
|
if input.Bidirectional {
|
|
queries = append(queries, input.reverseDirection().toSQL1(2, toSQL1Options{
|
|
skipWithClause: true,
|
|
reverseDirection: true,
|
|
mainTableRequired: mainTableRequired,
|
|
}))
|
|
}
|
|
if input.PreviousPeriod {
|
|
queries = append(queries, input.previousPeriod().toSQL1(3, toSQL1Options{
|
|
skipWithClause: true,
|
|
offsetedStart: input.Start,
|
|
mainTableRequired: mainTableRequired,
|
|
}))
|
|
}
|
|
if input.Bidirectional && input.PreviousPeriod {
|
|
queries = append(queries, input.reverseDirection().previousPeriod().toSQL1(4, toSQL1Options{
|
|
skipWithClause: true,
|
|
reverseDirection: true,
|
|
offsetedStart: input.Start,
|
|
mainTableRequired: mainTableRequired,
|
|
}))
|
|
}
|
|
return queries
|
|
}
|
|
|
|
func (c *Component) graphLineHandlerFunc(gc *gin.Context) {
|
|
ctx := c.t.Context(gc.Request.Context())
|
|
input := graphLineHandlerInput{graphCommonHandlerInput: graphCommonHandlerInput{schema: c.d.Schema}}
|
|
if err := gc.ShouldBindJSON(&input); err != nil {
|
|
gc.JSON(http.StatusBadRequest, gin.H{"message": helpers.Capitalize(err.Error())})
|
|
return
|
|
}
|
|
if err := query.Columns(input.Dimensions).Validate(input.schema); err != nil {
|
|
gc.JSON(http.StatusBadRequest, gin.H{"message": helpers.Capitalize(err.Error())})
|
|
return
|
|
}
|
|
if err := input.Filter.Validate(input.schema); err != nil {
|
|
gc.JSON(http.StatusBadRequest, gin.H{"message": helpers.Capitalize(err.Error())})
|
|
return
|
|
}
|
|
if input.Limit > c.config.DimensionsLimit {
|
|
gc.JSON(http.StatusBadRequest,
|
|
gin.H{"message": fmt.Sprintf("Limit is set beyond maximum value (%d)",
|
|
c.config.DimensionsLimit)})
|
|
return
|
|
}
|
|
|
|
queries := input.toSQL()
|
|
sqlQuery := c.finalizeTemplateQueries(queries)
|
|
gc.Header("X-SQL-Query", strings.ReplaceAll(sqlQuery, "\n", " "))
|
|
|
|
results := []struct {
|
|
Axis uint8 `ch:"axis"`
|
|
Time time.Time `ch:"time"`
|
|
Xps float64 `ch:"xps"`
|
|
Dimensions []string `ch:"dimensions"`
|
|
}{}
|
|
if err := c.d.ClickHouseDB.Conn.Select(ctx, &results, sqlQuery); err != nil {
|
|
c.r.Err(err).Str("query", sqlQuery).Msg("unable to query database")
|
|
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Unable to query database."})
|
|
return
|
|
}
|
|
|
|
// When requesting the previous period, we get an empty dimension in
|
|
// results. Put it back.
|
|
if len(input.Dimensions) > 0 {
|
|
zeroDimensions := make([]string, len(input.Dimensions))
|
|
for idx := range zeroDimensions {
|
|
zeroDimensions[idx] = "Other"
|
|
}
|
|
for idx := range results {
|
|
if len(results[idx].Dimensions) == 0 {
|
|
results[idx].Dimensions = zeroDimensions
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set time axis. We assume the first returned axis has the complete view.
|
|
output := graphLineHandlerOutput{
|
|
Time: []time.Time{},
|
|
}
|
|
lastTime := time.Time{}
|
|
for _, result := range results {
|
|
if result.Axis == 1 && !result.Time.Equal(lastTime) {
|
|
output.Time = append(output.Time, result.Time)
|
|
lastTime = result.Time
|
|
}
|
|
}
|
|
|
|
// For the remaining, we will collect information into various
|
|
// structures in one pass. Each structure will be keyed by the
|
|
// axis and the row.
|
|
axes := []int{} // list of axes
|
|
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)
|
|
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)
|
|
lasts := map[int]map[string]int{} // for each axis, a map from row to last(for sorting purpose)
|
|
lastTimeForAxis := map[int]time.Time{}
|
|
timeIndexForAxis := map[int]int{}
|
|
for _, result := range results {
|
|
var ok bool
|
|
axis := int(result.Axis)
|
|
lastTime, ok = lastTimeForAxis[axis]
|
|
if !ok {
|
|
// Unknown axis, initialize various structs
|
|
axes = append(axes, axis)
|
|
lastTimeForAxis[axis] = time.Time{}
|
|
timeIndexForAxis[axis] = -1
|
|
rows[axis] = map[string][]string{}
|
|
points[axis] = map[string][]int{}
|
|
sums[axis] = map[string]uint64{}
|
|
maxes[axis] = map[string]uint64{}
|
|
lasts[axis] = map[string]int{}
|
|
}
|
|
if !result.Time.Equal(lastTime) {
|
|
// New timestamp, increment time index
|
|
timeIndexForAxis[axis]++
|
|
lastTimeForAxis[axis] = result.Time
|
|
}
|
|
rowKey := fmt.Sprintf("%d-%s", axis, result.Dimensions)
|
|
_, ok = points[axis][rowKey]
|
|
if !ok {
|
|
// Not points for this row yet, create it
|
|
rows[axis][rowKey] = result.Dimensions
|
|
row := make([]int, len(output.Time))
|
|
points[axis][rowKey] = row
|
|
sums[axis][rowKey] = 0
|
|
maxes[axis][rowKey] = 0
|
|
lasts[axis][rowKey] = 0
|
|
}
|
|
points[axis][rowKey][timeIndexForAxis[axis]] = int(result.Xps)
|
|
sums[axis][rowKey] += uint64(result.Xps)
|
|
if uint64(result.Xps) > maxes[axis][rowKey] {
|
|
maxes[axis][rowKey] = uint64(result.Xps)
|
|
}
|
|
lasts[axis][rowKey] = int(result.Xps)
|
|
}
|
|
// Sort axes
|
|
sort.Ints(axes)
|
|
// Sort the rows using the sums
|
|
sortedRowKeys := map[int][]string{}
|
|
for _, axis := range axes {
|
|
sortedRowKeys[axis] = make([]string, 0, len(rows[axis]))
|
|
for k := range rows[axis] {
|
|
sortedRowKeys[axis] = append(sortedRowKeys[axis], k)
|
|
}
|
|
sort.Slice(sortedRowKeys[axis], func(i, j int) bool {
|
|
iKey := sortedRowKeys[axis][i]
|
|
jKey := sortedRowKeys[axis][j]
|
|
if rows[axis][iKey][0] == "Other" {
|
|
return false
|
|
}
|
|
if rows[axis][jKey][0] == "Other" {
|
|
return true
|
|
}
|
|
if input.LimitType == "max" {
|
|
return maxes[axis][iKey] > maxes[axis][jKey]
|
|
}
|
|
if input.LimitType == "last" {
|
|
return lasts[axis][iKey] > lasts[axis][jKey]
|
|
}
|
|
|
|
return sums[axis][iKey] > sums[axis][jKey]
|
|
})
|
|
}
|
|
|
|
// Now, we can complete the `output' structure!
|
|
totalRows := 0
|
|
for _, axis := range axes {
|
|
totalRows += len(rows[axis])
|
|
}
|
|
output.Rows = make([][]string, totalRows)
|
|
output.Axis = make([]int, totalRows)
|
|
output.AxisNames = make(map[int]string)
|
|
output.Points = make([][]int, totalRows)
|
|
output.Average = make([]int, totalRows)
|
|
output.Min = make([]int, totalRows)
|
|
output.Max = make([]int, totalRows)
|
|
output.Last = make([]int, totalRows)
|
|
output.NinetyFivePercentile = make([]int, totalRows)
|
|
|
|
i := -1
|
|
for _, axis := range axes {
|
|
for _, k := range sortedRowKeys[axis] {
|
|
i++
|
|
output.Rows[i] = rows[axis][k]
|
|
output.Axis[i] = axis
|
|
output.Points[i] = points[axis][k]
|
|
output.Average[i] = int(sums[axis][k] / uint64(len(output.Time)))
|
|
|
|
// Last
|
|
// We use the second last value (-2) because
|
|
// the last value is not show in the graph.
|
|
output.Last[i] = points[axis][k][len(output.Time)-2]
|
|
|
|
// For remaining, we will sort the values. It
|
|
// is needed for 95th percentile but it helps
|
|
// for min/max too. We remove special cases
|
|
// for 0 or 1 point.
|
|
nbPoints := len(output.Points[i])
|
|
if nbPoints == 0 {
|
|
continue
|
|
}
|
|
if nbPoints == 1 {
|
|
v := output.Points[i][0]
|
|
output.Min[i] = v
|
|
output.Max[i] = v
|
|
output.Last[i] = v
|
|
output.NinetyFivePercentile[i] = v
|
|
continue
|
|
}
|
|
|
|
points := make([]int, nbPoints)
|
|
copy(points, output.Points[i])
|
|
sort.Ints(points)
|
|
|
|
// Min (but not 0)
|
|
for j := range nbPoints {
|
|
output.Min[i] = points[j]
|
|
if points[j] > 0 {
|
|
break
|
|
}
|
|
}
|
|
// Max
|
|
output.Max[i] = points[nbPoints-1]
|
|
// 95th percentile with linear interpolation
|
|
index := 0.95 * float64(nbPoints-1)
|
|
j := int(index)
|
|
fraction := index - float64(j)
|
|
output.NinetyFivePercentile[i] = points[nbPoints-1]
|
|
if j >= 0 && j < nbPoints-1 {
|
|
output.NinetyFivePercentile[i] = int(
|
|
math.Round(float64(points[j])*(1-fraction) + float64(points[j+1])*fraction))
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, axis := range output.Axis {
|
|
switch axis {
|
|
case 1:
|
|
output.AxisNames[axis] = "Direct"
|
|
case 2:
|
|
output.AxisNames[axis] = "Reverse"
|
|
case 3, 4:
|
|
diff := input.End.Sub(input.Start)
|
|
_, name := nearestPeriod(diff)
|
|
output.AxisNames[axis] = fmt.Sprintf("Previous %s", name)
|
|
}
|
|
}
|
|
gc.JSON(http.StatusOK, output)
|
|
}
|
|
|
|
type tableIntervalInput struct {
|
|
Start time.Time `json:"start" binding:"required"`
|
|
End time.Time `json:"end" binding:"required,gtfield=Start"`
|
|
Points uint `json:"points" binding:"required,min=5,max=2000"` // minimum number of points
|
|
}
|
|
|
|
type tableIntervalOutput struct {
|
|
Table string `json:"table"`
|
|
Interval uint64 `json:"interval"`
|
|
}
|
|
|
|
func (c *Component) getTableAndIntervalHandlerFunc(gc *gin.Context) {
|
|
var input tableIntervalInput
|
|
if err := gc.ShouldBindJSON(&input); err != nil {
|
|
gc.JSON(http.StatusBadRequest, gin.H{"message": helpers.Capitalize(err.Error())})
|
|
return
|
|
}
|
|
table, interval, _ := c.computeTableAndInterval(inputContext{
|
|
Points: input.Points,
|
|
Start: input.Start,
|
|
End: input.End,
|
|
})
|
|
|
|
gc.JSON(http.StatusOK, tableIntervalOutput{Table: table, Interval: uint64(interval.Seconds())})
|
|
}
|