From 1bce4e727e162fe7d8f2de1d2205ae33f3da0296 Mon Sep 17 00:00:00 2001 From: Jesibu Date: Thu, 11 Aug 2022 03:05:34 +0200 Subject: [PATCH] feat(api): Configurable response time badge thresholds (#309) * recreated all changes for setting thresholds on Uptime Badges * Suggestion accepted: Update core/ui/ui.go Co-authored-by: TwiN * Suggestion accepted: Update core/ui/ui.go Co-authored-by: TwiN * implemented final suggestions by Twin * Update controller/handler/badge.go * Update README.md * test: added the suggestons to set the UiConfig at another line Co-authored-by: TwiN --- README.md | 19 +++++++ config/config.go | 11 ++++ controller/handler/badge.go | 89 +++++++++++++++++--------------- controller/handler/badge_test.go | 89 +++++++++++++++++++++++++++++++- controller/handler/handler.go | 2 +- core/endpoint.go | 4 ++ core/ui/ui.go | 38 +++++++++++++- 7 files changed, 205 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 97e76f1b..9cbe8398 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ If you want to test it locally, see [Docker](#docker). | `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` | | `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` | +| `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` | | `alerting` | [Alerting configuration](#alerting). | `{}` | | `security` | [Security configuration](#security). | `{}` | | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | @@ -1469,6 +1470,24 @@ Where: - `{duration}` is `7d`, `24h` or `1h` - `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. +##### How to change the color thresholds of the response time badge +To change the response time badges threshold, a corresponding configuration can be added to an endpoint. +The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad] +All five values must be given in milliseconds (ms). + +``` +endpoints: +- name: nas + group: internal + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + ui: + badge: + response-time: + thresholds: [550, 850, 1350, 1650, 1750] +``` ### API Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history. diff --git a/config/config.go b/config/config.go index 17442157..eee0a1d0 100644 --- a/config/config.go +++ b/config/config.go @@ -16,6 +16,7 @@ import ( "github.com/TwiN/gatus/v4/core" "github.com/TwiN/gatus/v4/security" "github.com/TwiN/gatus/v4/storage" + "github.com/TwiN/gatus/v4/util" "gopkg.in/yaml.v2" ) @@ -94,6 +95,16 @@ type Config struct { lastFileModTime time.Time // last modification time } +func (config *Config) GetEndpointByKey(key string) *core.Endpoint { + for i := 0; i < len(config.Endpoints); i++ { + ep := config.Endpoints[i] + if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key { + return ep + } + } + return nil +} + // HasLoadedConfigurationFileBeenModified returns whether the file that the // configuration has been loaded from has been modified since it was last read func (config Config) HasLoadedConfigurationFileBeenModified() bool { diff --git a/controller/handler/badge.go b/controller/handler/badge.go index 99bae67b..687550c9 100644 --- a/controller/handler/badge.go +++ b/controller/handler/badge.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/TwiN/gatus/v4/config" "github.com/TwiN/gatus/v4/storage/store" "github.com/TwiN/gatus/v4/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common/paging" @@ -28,6 +29,10 @@ const ( HealthStatusUnknown = "?" ) +var ( + badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad} +) + // UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // // Valid values for {duration}: 7d, 24h, 1h @@ -68,38 +73,40 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) { // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // // Valid values for {duration}: 7d, 24h, 1h -func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) { - variables := mux.Vars(request) - duration := variables["duration"] - var from time.Time - switch duration { - case "7d": - from = time.Now().Add(-7 * 24 * time.Hour) - case "24h": - from = time.Now().Add(-24 * time.Hour) - case "1h": - from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little - default: - http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest) - return - } - key := variables["key"] - averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) - if err != nil { - if err == common.ErrEndpointNotFound { - http.Error(writer, err.Error(), http.StatusNotFound) - } else if err == common.ErrInvalidTimeRange { - http.Error(writer, err.Error(), http.StatusBadRequest) - } else { - http.Error(writer, err.Error(), http.StatusInternalServerError) +func ResponseTimeBadge(config *config.Config) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + variables := mux.Vars(request) + duration := variables["duration"] + var from time.Time + switch duration { + case "7d": + from = time.Now().Add(-7 * 24 * time.Hour) + case "24h": + from = time.Now().Add(-24 * time.Hour) + case "1h": + from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little + default: + http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest) + return } - return + key := variables["key"] + averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) + if err != nil { + if err == common.ErrEndpointNotFound { + http.Error(writer, err.Error(), http.StatusNotFound) + } else if err == common.ErrInvalidTimeRange { + http.Error(writer, err.Error(), http.StatusBadRequest) + } else { + http.Error(writer, err.Error(), http.StatusInternalServerError) + } + return + } + writer.Header().Set("Content-Type", "image/svg+xml") + writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + writer.Header().Set("Expires", "0") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config)) } - writer.Header().Set("Content-Type", "image/svg+xml") - writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - writer.Header().Set("Expires", "0") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime)) } // HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed. @@ -199,7 +206,7 @@ func getBadgeColorFromUptime(uptime float64) string { return badgeColorHexVeryBad } -func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte { +func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte { var labelWidth, valueWidth int switch duration { case "7d": @@ -210,7 +217,7 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by labelWidth = 105 default: } - color := getBadgeColorFromResponseTime(averageResponseTime) + color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg) sanitizedValue := strconv.Itoa(averageResponseTime) + "ms" valueWidth = len(sanitizedValue) * 11 width := labelWidth + valueWidth @@ -247,17 +254,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by return svg } -func getBadgeColorFromResponseTime(responseTime int) string { - if responseTime <= 50 { - return badgeColorHexAwesome - } else if responseTime <= 200 { - return badgeColorHexGreat - } else if responseTime <= 300 { - return badgeColorHexGood - } else if responseTime <= 500 { - return badgeColorHexPassable - } else if responseTime <= 750 { - return badgeColorHexBad +func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string { + endpoint := cfg.GetEndpointByKey(key) + // the threshold config requires 5 values, so we can be sure it's set here + for i := 0; i < 5; i++ { + if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] { + return badgeColors[i] + } } return badgeColorHexVeryBad } diff --git a/controller/handler/badge_test.go b/controller/handler/badge_test.go index c4260664..d5647120 100644 --- a/controller/handler/badge_test.go +++ b/controller/handler/badge_test.go @@ -9,6 +9,7 @@ import ( "github.com/TwiN/gatus/v4/config" "github.com/TwiN/gatus/v4/core" + "github.com/TwiN/gatus/v4/core/ui" "github.com/TwiN/gatus/v4/storage/store" "github.com/TwiN/gatus/v4/watchdog" ) @@ -29,6 +30,36 @@ func TestBadge(t *testing.T) { }, }, } + + testSuccessfulResult = core.Result{ + Hostname: "example.org", + IP: "127.0.0.1", + HTTPStatus: 200, + Errors: nil, + Connected: true, + Success: true, + Timestamp: timestamp, + Duration: 150 * time.Millisecond, + CertificateExpiration: 10 * time.Hour, + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: true, + }, + { + Condition: "[RESPONSE_TIME] < 500", + Success: true, + }, + { + Condition: "[CERTIFICATE_EXPIRATION] < 72h", + Success: true, + }, + }, + } + + cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig() + cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig() + watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) router := CreateRouter("../../web/static", cfg) @@ -180,7 +211,61 @@ func TestGetBadgeColorFromUptime(t *testing.T) { } } +var ( + firstCondition = core.Condition("[STATUS] == 200") + secondCondition = core.Condition("[RESPONSE_TIME] < 500") + thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") +) + func TestGetBadgeColorFromResponseTime(t *testing.T) { + defer store.Get().Clear() + defer cache.Clear() + + testEndpoint = core.Endpoint{ + Name: "name", + Group: "group", + URL: "https://example.org/what/ever", + Method: "GET", + Body: "body", + Interval: 30 * time.Second, + Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, + Alerts: nil, + NumberOfFailuresInARow: 0, + NumberOfSuccessesInARow: 0, + UIConfig: ui.GetDefaultConfig(), + } + testSuccessfulResult = core.Result{ + Hostname: "example.org", + IP: "127.0.0.1", + HTTPStatus: 200, + Errors: nil, + Connected: true, + Success: true, + Timestamp: timestamp, + Duration: 150 * time.Millisecond, + CertificateExpiration: 10 * time.Hour, + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: true, + }, + { + Condition: "[RESPONSE_TIME] < 500", + Success: true, + }, + { + Condition: "[CERTIFICATE_EXPIRATION] < 72h", + Success: true, + }, + }, + } + cfg := &config.Config{ + Metrics: true, + Endpoints: []*core.Endpoint{&testEndpoint}, + } + + store.Get().Insert(&testEndpoint, &testSuccessfulResult) + scenarios := []struct { ResponseTime int ExpectedColor string @@ -228,8 +313,8 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) { } for _, scenario := range scenarios { t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) { - if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor { - t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime)) + if getBadgeColorFromResponseTime(scenario.ResponseTime, "group_name", cfg) != scenario.ExpectedColor { + t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, "group_name", cfg)) } }) } diff --git a/controller/handler/handler.go b/controller/handler/handler.go index 0bdb1671..c75c4250 100644 --- a/controller/handler/handler.go +++ b/controller/handler/handler.go @@ -31,7 +31,7 @@ func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router { protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET") - unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET") + unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge(cfg)).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET") // Misc router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") diff --git a/core/endpoint.go b/core/endpoint.go index a03eca69..b53e3659 100644 --- a/core/endpoint.go +++ b/core/endpoint.go @@ -145,6 +145,10 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { } if endpoint.UIConfig == nil { endpoint.UIConfig = ui.GetDefaultConfig() + } else { + if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil { + return err + } } if endpoint.Interval == 0 { endpoint.Interval = 1 * time.Minute diff --git a/core/ui/ui.go b/core/ui/ui.go index 9346b658..f481a586 100644 --- a/core/ui/ui.go +++ b/core/ui/ui.go @@ -1,5 +1,7 @@ package ui +import "errors" + // Config is the UI configuration for core.Endpoint type Config struct { // HideHostname whether to hide the hostname in the Result @@ -7,7 +9,36 @@ type Config struct { // HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. HideURL bool `yaml:"hide-url"` // DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI - DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"` + DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"` + // Badge is the configuration for the badges generated + Badge *Badge `yaml:"badge"` +} + +type Badge struct { + ResponseTime *ResponseTime `yaml:"response-time"` +} +type ResponseTime struct { + Thresholds []int `yaml:"thresholds"` +} + +var ( + ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values") +) + +func (config *Config) ValidateAndSetDefaults() error { + if config.Badge != nil { + if len(config.Badge.ResponseTime.Thresholds) != 5 { + return ErrInvalidBadgeResponseTimeConfig + } + for i := 4; i > 0; i-- { + if config.Badge.ResponseTime.Thresholds[i] < config.Badge.ResponseTime.Thresholds[i-1] { + return ErrInvalidBadgeResponseTimeConfig + } + } + } else { + config.Badge = GetDefaultConfig().Badge + } + return nil } // GetDefaultConfig retrieves the default UI configuration @@ -16,5 +47,10 @@ func GetDefaultConfig() *Config { HideHostname: false, HideURL: false, DontResolveFailedConditions: false, + Badge: &Badge{ + ResponseTime: &ResponseTime{ + Thresholds: []int{50, 200, 300, 500, 750}, + }, + }, } }