diff --git a/console/clickhouse.go b/console/clickhouse.go index 8e442980..15159b05 100644 --- a/console/clickhouse.go +++ b/console/clickhouse.go @@ -13,9 +13,15 @@ import ( "text/template" "time" + "github.com/gin-gonic/gin" + "net/http" + + "akvorado/common/helpers" "akvorado/console/query" ) +const defaultPointsNumber = 200 + // flowsTable describe a consolidated or unconsolidated flows table. type flowsTable struct { Name string @@ -117,6 +123,11 @@ type context struct { ToStartOfInterval func(string) string } +type tableIntervalResult struct { + Table string `json:"table"` + Interval uint64 `json:"interval"` +} + // templateEscape escapes `{{` and `}}` from a string. In fact, only // the opening tag needs to be escaped. func templateEscape(input string) string { @@ -140,26 +151,24 @@ func templateContext(context inputContext) string { return fmt.Sprintf("context `%s`", string(encoded)) } +func (c *Component) getTableAndIntervalHandlerFunc(gc *gin.Context) { + input := inputContext{Points: defaultPointsNumber} + if err := gc.ShouldBindJSON(&input); err != nil { + gc.JSON(http.StatusBadRequest, gin.H{"message": helpers.Capitalize(err.Error())}) + return + } + table, interval, _ := c.computeTableAndInterval(input) + + gc.JSON(http.StatusOK, tableIntervalResult{Table: table, Interval: uint64(interval.Seconds())}) +} + func (c *Component) contextFunc(inputStr string) context { var input inputContext if err := json.Unmarshal([]byte(inputStr), &input); err != nil { panic(err) } - targetInterval := time.Duration(uint64(input.End.Sub(input.Start)) / uint64(input.Points)) - if targetInterval < time.Second { - targetInterval = time.Second - } - - // Select table - targetIntervalForTableSelection := targetInterval - if input.MainTableRequired { - targetIntervalForTableSelection = time.Second - } - table, computedInterval := c.getBestTable(input.Start, targetIntervalForTableSelection) - if input.StartForInterval != nil { - _, computedInterval = c.getBestTable(*input.StartForInterval, targetIntervalForTableSelection) - } + table, computedInterval, targetInterval := c.computeTableAndInterval(input) // Make start/end match the computed interval (currently equal to the table resolution) start := input.Start.Truncate(computedInterval) @@ -224,6 +233,24 @@ func (c *Component) contextFunc(inputStr string) context { } } +func (c *Component) computeTableAndInterval(input inputContext) (string, time.Duration, time.Duration) { + targetInterval := time.Duration(uint64(input.End.Sub(input.Start)) / uint64(input.Points)) + if targetInterval < time.Second { + targetInterval = time.Second + } + + // Select table + targetIntervalForTableSelection := targetInterval + if input.MainTableRequired { + targetIntervalForTableSelection = time.Second + } + table, computedInterval := c.getBestTable(input.Start, targetIntervalForTableSelection) + if input.StartForInterval != nil { + _, computedInterval = c.getBestTable(*input.StartForInterval, targetIntervalForTableSelection) + } + return table, computedInterval, targetInterval +} + // Get the best table starting at the specified time. func (c *Component) getBestTable(start time.Time, targetInterval time.Duration) (string, time.Duration) { c.flowsTablesLock.RLock() diff --git a/console/clickhouse_test.go b/console/clickhouse_test.go index 53ea949b..df76e14f 100644 --- a/console/clickhouse_test.go +++ b/console/clickhouse_test.go @@ -300,3 +300,81 @@ func TestFinalizeQuery(t *testing.T) { }) } } + +func TestGetTableInterval(t *testing.T) { + cases := []struct { + Description string + Tables []flowsTable + Context inputContext + Expected tableIntervalResult + }{ + { + Description: "simple query without additional tables", + Context: inputContext{ + Start: time.Date(2022, 4, 10, 15, 45, 10, 0, time.UTC), + End: time.Date(2022, 4, 11, 15, 45, 10, 0, time.UTC), + Points: 86400, + }, + Expected: tableIntervalResult{Table: "flows", Interval: 1}, + }, { + Description: "query with main table", + Context: inputContext{ + Start: time.Date(2022, 4, 10, 15, 45, 10, 0, time.UTC), + End: time.Date(2022, 4, 11, 15, 45, 10, 0, time.UTC), + MainTableRequired: true, + Points: 86400, + }, + Expected: tableIntervalResult{Table: "flows", Interval: 1}, + }, { + Description: "only flows table available", + Tables: []flowsTable{{"flows", 0, time.Date(2022, 3, 10, 15, 45, 10, 0, time.UTC)}}, + Context: inputContext{ + Start: time.Date(2022, 4, 10, 15, 45, 10, 0, time.UTC), + End: time.Date(2022, 4, 11, 15, 45, 10, 0, time.UTC), + Points: 86400, + }, + Expected: tableIntervalResult{Table: "flows", Interval: 1}, + }, { + Description: "select flows table out of range", + Tables: []flowsTable{ + {"flows", 0, time.Date(2022, 4, 10, 16, 45, 10, 0, time.UTC)}, + {"flows_1m0s", time.Minute, time.Date(2022, 4, 10, 17, 45, 10, 0, time.UTC)}, + }, + Context: inputContext{ + Start: time.Date(2022, 4, 10, 15, 45, 10, 0, time.UTC), + End: time.Date(2022, 4, 11, 15, 45, 10, 0, time.UTC), + Points: 720, // 2-minute resolution, + }, + Expected: tableIntervalResult{Table: "flows", Interval: 1}, + }, { + Description: "select consolidated table with better resolution", + Tables: []flowsTable{ + {"flows", 0, time.Date(2022, 3, 10, 22, 45, 10, 0, time.UTC)}, + {"flows_5m0s", 5 * time.Minute, time.Date(2022, 4, 2, 22, 45, 10, 0, time.UTC)}, + {"flows_1m0s", time.Minute, time.Date(2022, 4, 2, 22, 45, 10, 0, time.UTC)}, + }, + Context: inputContext{ + Start: time.Date(2022, 4, 10, 15, 45, 10, 0, time.UTC), + End: time.Date(2022, 4, 11, 15, 45, 10, 0, time.UTC), + Points: 720, // 2-minute resolution, + }, + Expected: tableIntervalResult{Table: "flows_1m0s", Interval: 60}, + }, + } + + c, _, _, _ := NewMock(t, DefaultConfiguration()) + for _, tc := range cases { + t.Run(tc.Description, func(t *testing.T) { + c.flowsTables = tc.Tables + table, interval, _ := c.computeTableAndInterval( + tc.Context) + got := tableIntervalResult{ + Table: table, + Interval: uint64(interval.Seconds()), + } + if diff := helpers.Diff(got, tc.Expected); diff != "" { + t.Fatalf("ComputeBestTableAndInterval(): (-got, +want):\n%s", diff) + } + }) + } +} diff --git a/console/root.go b/console/root.go index c8c3eb80..82a1bbc5 100644 --- a/console/root.go +++ b/console/root.go @@ -94,6 +94,7 @@ func (c *Component) Start() error { endpoint.GET("/widget/graph", c.d.HTTP.CacheByRequestPath(5*time.Minute), c.widgetGraphHandlerFunc) endpoint.POST("/graph/line", c.d.HTTP.CacheByRequestBody(c.config.CacheTTL), c.graphLineHandlerFunc) endpoint.POST("/graph/sankey", c.d.HTTP.CacheByRequestBody(c.config.CacheTTL), c.graphSankeyHandlerFunc) + endpoint.POST("/graph/table-interval", c.getTableAndIntervalHandlerFunc) endpoint.POST("/filter/validate", c.filterValidateHandlerFunc) endpoint.POST("/filter/complete", c.d.HTTP.CacheByRequestBody(time.Minute), c.filterCompleteHandlerFunc) endpoint.GET("/filter/saved", c.filterSavedListHandlerFunc) diff --git a/console/tests.go b/console/tests.go index f0192f64..152fd57e 100644 --- a/console/tests.go +++ b/console/tests.go @@ -21,7 +21,7 @@ import ( "akvorado/console/database" ) -// NewMock instantiantes a new authentication component +// NewMock instantiates a new authentication component func NewMock(t *testing.T, config Configuration) (*Component, *httpserver.Component, *mocks.MockConn, *clock.Mock) { t.Helper() r := reporter.NewMock(t)