mirror of
https://github.com/TwiN/gatus.git
synced 2024-12-14 11:58:04 +00:00
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:
parent
1aa94a3365
commit
1bce4e727e
7 changed files with 205 additions and 47 deletions
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,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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -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},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue