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:
		
							
								
								
									
										19
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								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 `<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 | ||||
| Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history. | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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,7 +73,8 @@ 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) { | ||||
| 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 | ||||
| @ -99,7 +105,8 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) { | ||||
| 		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)) | ||||
| 		_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config)) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // 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 | ||||
| } | ||||
|  | ||||
| @ -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)) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| @ -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") | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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 | ||||
| @ -8,6 +10,35 @@ type Config struct { | ||||
| 	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"` | ||||
| 	// 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}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user