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 <twin@linux.com>

* Suggestion accepted: Update core/ui/ui.go

Co-authored-by: TwiN <twin@linux.com>

* 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 <twin@linux.com>
This commit is contained in:
Jesibu 2022-08-11 03:05:34 +02:00 committed by GitHub
parent 1aa94a3365
commit 1bce4e727e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 205 additions and 47 deletions

View File

@ -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-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.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.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). | `{}` | | `alerting` | [Alerting configuration](#alerting). | `{}` |
| `security` | [Security configuration](#security). | `{}` | | `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
@ -1469,6 +1470,24 @@ Where:
- `{duration}` is `7d`, `24h` or `1h` - `{duration}` is `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. - `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` 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 ### API
Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history. Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.

View File

@ -16,6 +16,7 @@ import (
"github.com/TwiN/gatus/v4/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/security" "github.com/TwiN/gatus/v4/security"
"github.com/TwiN/gatus/v4/storage" "github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v4/util"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -94,6 +95,16 @@ type Config struct {
lastFileModTime time.Time // last modification time 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 // HasLoadedConfigurationFileBeenModified returns whether the file that the
// configuration has been loaded from has been modified since it was last read // configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationFileBeenModified() bool { func (config Config) HasLoadedConfigurationFileBeenModified() bool {

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging" "github.com/TwiN/gatus/v4/storage/store/common/paging"
@ -28,6 +29,10 @@ const (
HealthStatusUnknown = "?" 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. // UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for {duration}: 7d, 24h, 1h // 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. // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for {duration}: 7d, 24h, 1h // Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) { func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
variables := mux.Vars(request) return func(writer http.ResponseWriter, request *http.Request) {
duration := variables["duration"] variables := mux.Vars(request)
var from time.Time duration := variables["duration"]
switch duration { var from time.Time
case "7d": switch duration {
from = time.Now().Add(-7 * 24 * time.Hour) case "7d":
case "24h": from = time.Now().Add(-7 * 24 * time.Hour)
from = time.Now().Add(-24 * time.Hour) case "24h":
case "1h": from = time.Now().Add(-24 * time.Hour)
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little case "1h":
default: from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest) default:
return 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)
} }
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. // 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 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 var labelWidth, valueWidth int
switch duration { switch duration {
case "7d": case "7d":
@ -210,7 +217,7 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
labelWidth = 105 labelWidth = 105
default: default:
} }
color := getBadgeColorFromResponseTime(averageResponseTime) color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms" sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11 valueWidth = len(sanitizedValue) * 11
width := labelWidth + valueWidth width := labelWidth + valueWidth
@ -247,17 +254,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
return svg return svg
} }
func getBadgeColorFromResponseTime(responseTime int) string { func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
if responseTime <= 50 { endpoint := cfg.GetEndpointByKey(key)
return badgeColorHexAwesome // the threshold config requires 5 values, so we can be sure it's set here
} else if responseTime <= 200 { for i := 0; i < 5; i++ {
return badgeColorHexGreat if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
} else if responseTime <= 300 { return badgeColors[i]
return badgeColorHexGood }
} else if responseTime <= 500 {
return badgeColorHexPassable
} else if responseTime <= 750 {
return badgeColorHexBad
} }
return badgeColorHexVeryBad return badgeColorHexVeryBad
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/TwiN/gatus/v4/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core" "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/storage/store"
"github.com/TwiN/gatus/v4/watchdog" "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[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()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg) 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) { 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 { scenarios := []struct {
ResponseTime int ResponseTime int
ExpectedColor string ExpectedColor string
@ -228,8 +313,8 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) { t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor { if getBadgeColorFromResponseTime(scenario.ResponseTime, "group_name", cfg) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime)) t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, "group_name", cfg))
} }
}) })
} }

View File

@ -31,7 +31,7 @@ func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET") 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}/health/badge.svg", HealthBadge).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).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") unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// Misc // Misc
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")

View File

@ -145,6 +145,10 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
} }
if endpoint.UIConfig == nil { if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() endpoint.UIConfig = ui.GetDefaultConfig()
} else {
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil {
return err
}
} }
if endpoint.Interval == 0 { if endpoint.Interval == 0 {
endpoint.Interval = 1 * time.Minute endpoint.Interval = 1 * time.Minute

View File

@ -1,5 +1,7 @@
package ui package ui
import "errors"
// Config is the UI configuration for core.Endpoint // Config is the UI configuration for core.Endpoint
type Config struct { type Config struct {
// HideHostname whether to hide the hostname in the Result // 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 whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
HideURL bool `yaml:"hide-url"` HideURL bool `yaml:"hide-url"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI // 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 // GetDefaultConfig retrieves the default UI configuration
@ -16,5 +47,10 @@ func GetDefaultConfig() *Config {
HideHostname: false, HideHostname: false,
HideURL: false, HideURL: false,
DontResolveFailedConditions: false, DontResolveFailedConditions: false,
Badge: &Badge{
ResponseTime: &ResponseTime{
Thresholds: []int{50, 200, 300, 500, 750},
},
},
} }
} }