Compare commits

...

21 Commits

Author SHA1 Message Date
TwinProduction
af6298de05 Add documentation for alerts 2020-08-22 14:15:44 -04:00
TwinProduction
22fef4e9aa Add tests for alert configuration 2020-08-22 14:15:21 -04:00
TwinProduction
9a3c9e4d61 Set default alert threshold to 3 2020-08-22 14:15:08 -04:00
TwinProduction
62f7bdbd63 Add favicon.ico and logo-small-padding.png 2020-08-21 22:17:53 -04:00
TwinProduction
04d6c8bb82 Improve mobile-friendliness and add logo 2020-08-21 22:07:46 -04:00
TwinProduction
e1721fa237 Update Go to 1.15 2020-08-21 21:57:23 -04:00
TwinProduction
6f4cf69c4e Implement Slack alerting (#2) 2020-08-20 21:11:22 -04:00
TwinProduction
6596d253aa Continue working on #2: Slack alerts 2020-08-19 19:41:01 -04:00
TwinProduction
857fe5eb8c Rename SendMessage to SendSlackMessage 2020-08-19 19:40:00 -04:00
TwinProduction
8abcab6a8f Start working on #2: Slack alerts 2020-08-18 22:24:00 -04:00
TwinProduction
0fd8bf4198 Add Go report card badge 2020-08-17 22:21:20 -04:00
TwinProduction
946101e995 Add documentation in watchdog.go 2020-08-17 20:25:29 -04:00
TwinProduction
f930687b4a Clean up code for len() function 2020-08-16 15:19:53 -04:00
TwinProduction
43aa31be58 Add missing yaml identifier to enable code highlighting 2020-08-15 18:34:05 -04:00
TwinProduction
adfee25a22 Update interval in config.yaml 2020-08-15 16:59:05 -04:00
TwinProduction
1f241ecdb3 Support Gzip and cache result to prevent wasting CPU 2020-08-15 16:44:28 -04:00
TwinProduction
7849cc6dd4 Regenerate the table only if there's a change 2020-08-15 16:42:47 -04:00
TwinProduction
a62eab58ef Update examples 2020-08-14 20:05:10 -04:00
TwinProduction
da92907873 Add support for getting the length of the string or the slice of a json path 2020-08-12 21:42:13 -04:00
TwinProduction
937b136e60 Update README.md 2020-07-24 18:38:35 -04:00
TwinProduction
12db0d7c40 Allocate more space for service name and host 2020-07-24 18:36:16 -04:00
25 changed files with 419 additions and 121 deletions

View File

@@ -1,6 +1,7 @@
# gatus ![Gatus](static/logo-with-name.png)
![build](https://github.com/TwinProduction/gatus/workflows/build/badge.svg?branch=master) ![build](https://github.com/TwinProduction/gatus/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwinProduction/gatus)](https://goreportcard.com/report/github.com/TwinProduction/gatus)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
A service health dashboard in Go that is meant to be used as a docker A service health dashboard in Go that is meant to be used as a docker
@@ -41,8 +42,9 @@ Note that you can also add environment variables in the your configuration file
### Configuration ### Configuration
| Parameter | Description | Default | | Parameter | Description | Default |
| ----------------------- | --------------------------------------------------------------- | -------------- | | --------------------------------- | --------------------------------------------------------------- | -------------- |
| `metrics` | Whether to expose metrics at /metrics | `false` | | `metrics` | Whether to expose metrics at /metrics | `false` |
| `alerting.slack` | Webhook to use for alerts of type `slack` | `""` |
| `services[].name` | Name of the service. Can be anything. | Required `""` | | `services[].name` | Name of the service. Can be anything. | Required `""` |
| `services[].url` | URL to send the request to | Required `""` | | `services[].url` | URL to send the request to | Required `""` |
| `services[].conditions` | Conditions used to determine the health of the service | `[]` | | `services[].conditions` | Conditions used to determine the health of the service | `[]` |
@@ -51,6 +53,10 @@ Note that you can also add environment variables in the your configuration file
| `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`) | `false` | | `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`) | `false` |
| `services[].body` | Request body | `""` | | `services[].body` | Request body | `""` |
| `services[].headers` | Request headers | `{}` | | `services[].headers` | Request headers | `{}` |
| `services[].alerts[].type` | Type of alert. Currently, only `slack` is supported | Required `""` |
| `services[].alerts[].enabled` | Whether to enable the alert | `false` |
| `services[].alerts[].threshold` | Number of failures in a row needed before triggering the alert | `3` |
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent | `""` |
### Conditions ### Conditions
@@ -58,7 +64,7 @@ Note that you can also add environment variables in the your configuration file
Here are some examples of conditions you can use: Here are some examples of conditions you can use:
| Condition | Description | Passing values | Failing values | | Condition | Description | Passing values | Failing values |
| ------------------------------------- | ----------------------------------------- | ------------------------ | ----------------------- | | -----------------------------| ------------------------------------------------------- | ------------------------ | ----------------------- |
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, 500 | | `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, 500 |
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, 400, 500 | | `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, 400, 500 |
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 | | `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 |
@@ -67,6 +73,8 @@ Here are some examples of conditions you can use:
| `[BODY] == 1` | The body must be equal to 1 | 1 | literally anything else | | `[BODY] == 1` | The body must be equal to 1 | 1 | literally anything else |
| `[BODY].data.id == 1` | The jsonpath `$.data.id` is equal to 1 | `{"data":{"id":1}}` | literally anything else | | `[BODY].data.id == 1` | The jsonpath `$.data.id` is equal to 1 | `{"data":{"id":1}}` | literally anything else |
| `[BODY].data[0].id == 1` | The jsonpath `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | literally anything else | | `[BODY].data[0].id == 1` | The jsonpath `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | literally anything else |
| `len([BODY].data) > 0` | Array at jsonpath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | `{"data":[{"id":1}]}` |
| `len([BODY].name) == 8` | String at jsonpath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
**NOTE**: `[BODY]` with JSON path (i.e. `[BODY].id == 1`) is currently in BETA. For the most part, the only thing that doesn't work is arrays. **NOTE**: `[BODY]` with JSON path (i.e. `[BODY].id == 1`) is currently in BETA. For the most part, the only thing that doesn't work is arrays.
@@ -105,9 +113,9 @@ See the [example](example) folder.
By setting `services[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter. By setting `services[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.
For instance, the following configuration: For instance, the following configuration:
``` ```yaml
services: services:
- name: properties - name: filter users by gender
url: http://localhost:8080/playground url: http://localhost:8080/playground
method: POST method: POST
graphql: true graphql: true
@@ -131,3 +139,27 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
```json ```json
{"query":" {\n user(gender: \"female\") {\n id\n name\n gender\n avatar\n }\n }"} {"query":" {\n user(gender: \"female\") {\n id\n name\n gender\n avatar\n }\n }"}
``` ```
### Configuring Slack alerts
```yaml
alerting:
slack: https://hooks.slack.com/services/**********/**********/**********
services:
- name: twinnation
interval: 30s
url: https://twinnation.org/health
alerts:
- type: slack
enabled: true
description: "healthcheck failed 3 times in a row"
- type: slack
enabled: true
threshold: 5
description: "healthcheck failed 5 times in a row"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
```

31
alerting/slack.go Normal file
View File

@@ -0,0 +1,31 @@
package alerting
import (
"bytes"
"encoding/json"
"fmt"
"github.com/TwinProduction/gatus/client"
"io/ioutil"
)
type requestBody struct {
Text string `json:"text"`
}
// SendSlackMessage sends a message to the given Slack webhook
func SendSlackMessage(webhookUrl, service, description string) error {
body, _ := json.Marshal(requestBody{Text: fmt.Sprintf("*[Gatus]*\n*service:* %s\n*description:* %s", service, description)})
response, err := client.GetHttpClient().Post(webhookUrl, "application/json", bytes.NewBuffer(body))
if err != nil {
return err
}
defer response.Body.Close()
output, err := ioutil.ReadAll(response.Body)
if err != nil {
return fmt.Errorf("unable to read response body: %v", err.Error())
}
if string(output) != "ok" {
return fmt.Errorf("error: %s", string(output))
}
return nil
}

View File

@@ -1,21 +1,17 @@
metrics: true metrics: true
services: services:
- name: twinnation - name: twinnation
interval: 10s interval: 30s
url: https://twinnation.org/health url: https://twinnation.org/health
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- "[BODY].status == UP" - "[BODY].status == UP"
- "[RESPONSE_TIME] < 1000" - "[RESPONSE_TIME] < 1000"
- name: twinnation-articles-api - name: twinnation-articles-api
interval: 10s interval: 30s
url: https://twinnation.org/api/v1/articles/24 url: https://twinnation.org/api/v1/articles/24
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- "[BODY].id == 24" - "[BODY].id == 24"
- "[BODY].tags[0] == spring" - "[BODY].tags[0] == spring"
- name: example - "len([BODY].tags) > 0"
url: https://example.org/
interval: 30s
conditions:
- "[STATUS] == 200"

View File

@@ -22,6 +22,7 @@ var (
type Config struct { type Config struct {
Metrics bool `yaml:"metrics"` Metrics bool `yaml:"metrics"`
Alerting *core.Alerting `yaml:"alerting"`
Services []*core.Service `yaml:"services"` Services []*core.Service `yaml:"services"`
} }

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"github.com/TwinProduction/gatus/core"
"testing" "testing"
"time" "time"
) )
@@ -23,6 +24,9 @@ services:
if err != nil { if err != nil {
t.Error("No error should've been returned") t.Error("No error should've been returned")
} }
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if len(config.Services) != 2 { if len(config.Services) != 2 {
t.Error("Should have returned two services") t.Error("Should have returned two services")
} }
@@ -58,6 +62,9 @@ services:
if err != nil { if err != nil {
t.Error("No error should've been returned") t.Error("No error should've been returned")
} }
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics { if config.Metrics {
t.Error("Metrics should've been false by default") t.Error("Metrics should've been false by default")
} }
@@ -81,6 +88,9 @@ services:
if err != nil { if err != nil {
t.Error("No error should've been returned") t.Error("No error should've been returned")
} }
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if !config.Metrics { if !config.Metrics {
t.Error("Metrics should have been true") t.Error("Metrics should have been true")
} }
@@ -107,3 +117,62 @@ badconfig:
t.Error("The error returned should have been of type ErrNoServiceInConfig") t.Error("The error returned should have been of type ErrNoServiceInConfig")
} }
} }
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
slack: "http://example.com"
services:
- name: twinnation
url: https://twinnation.org/actuator/health
alerts:
- type: slack
enabled: true
threshold: 7
description: "Healthcheck failed 7 times in a row"
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack != "http://example.com" {
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack)
}
if len(config.Services) != 1 {
t.Error("There should've been 1 service")
}
if config.Services[0].Url != "https://twinnation.org/actuator/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/actuator/health")
}
if config.Services[0].Interval != 10*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 10*time.Second)
}
if config.Services[0].Alerts == nil {
t.Fatal("The service alerts shouldn't have been nil")
}
if len(config.Services[0].Alerts) != 1 {
t.Fatal("There should've been 1 alert configured")
}
if !config.Services[0].Alerts[0].Enabled {
t.Error("The alert should've been enabled")
}
if config.Services[0].Alerts[0].Threshold != 7 {
t.Errorf("The threshold of the alert should've been %d, but it was %d", 7, config.Services[0].Alerts[0].Threshold)
}
if config.Services[0].Alerts[0].Type != core.SlackAlert {
t.Errorf("The type of the alert should've been %s, but it was %s", core.SlackAlert, config.Services[0].Alerts[0].Type)
}
if config.Services[0].Alerts[0].Description != "Healthcheck failed 7 times in a row" {
t.Errorf("The type of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Services[0].Alerts[0].Description)
}
}

22
core/alert.go Normal file
View File

@@ -0,0 +1,22 @@
package core
// Alert is the service's alert configuration
type Alert struct {
// Type of alert
Type AlertType `yaml:"type"`
// Enabled defines whether or not the alert is enabled
Enabled bool `yaml:"enabled"`
// Threshold is the number of failures in a row needed before triggering the alert
Threshold int `yaml:"threshold"`
// Description of the alert. Will be included in the alert sent.
Description string `yaml:"description"`
}
type AlertType string
const (
SlackAlert AlertType = "slack"
)

5
core/alerting.go Normal file
View File

@@ -0,0 +1,5 @@
package core
type Alerting struct {
Slack string `yaml:"slack"`
}

View File

@@ -41,10 +41,10 @@ func (c *Condition) evaluate(result *Result) bool {
return false return false
} }
conditionToDisplay := condition conditionToDisplay := condition
// If the condition isn't a success, return the resolved condition // If the condition isn't a success, return what the resolved condition was too
if !success { if !success {
log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition) log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition)
conditionToDisplay = resolvedCondition conditionToDisplay = fmt.Sprintf("%s (%s)", condition, resolvedCondition)
} }
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success}) result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success return success

View File

@@ -166,3 +166,21 @@ func TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *tes
t.Errorf("Condition '%s' should have been a failure", condition) t.Errorf("Condition '%s' should have been a failure", condition)
} }
} }
func TestCondition_evaluateWithBodySliceLength(t *testing.T) {
condition := Condition("len([BODY].data) == 3")
result := &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithBodyStringLength(t *testing.T) {
condition := Condition("len([BODY].name) == 8")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}

View File

@@ -26,6 +26,9 @@ type Service struct {
Headers map[string]string `yaml:"headers,omitempty"` Headers map[string]string `yaml:"headers,omitempty"`
Interval time.Duration `yaml:"interval,omitempty"` Interval time.Duration `yaml:"interval,omitempty"`
Conditions []*Condition `yaml:"conditions"` Conditions []*Condition `yaml:"conditions"`
Alerts []*Alert `yaml:"alerts"`
numberOfFailuresInARow int
} }
func (service *Service) Validate() { func (service *Service) Validate() {
@@ -39,6 +42,11 @@ func (service *Service) Validate() {
if len(service.Headers) == 0 { if len(service.Headers) == 0 {
service.Headers = make(map[string]string) service.Headers = make(map[string]string)
} }
for _, alert := range service.Alerts {
if alert.Threshold <= 0 {
alert.Threshold = 3
}
}
if len(service.Url) == 0 { if len(service.Url) == 0 {
panic(ErrNoUrl) panic(ErrNoUrl)
} }
@@ -68,9 +76,29 @@ func (service *Service) EvaluateConditions() *Result {
} }
} }
result.Timestamp = time.Now() result.Timestamp = time.Now()
if result.Success {
service.numberOfFailuresInARow = 0
// TODO: Send notification that alert has been resolved?
} else {
service.numberOfFailuresInARow++
}
return result return result
} }
func (service *Service) GetAlertsTriggered() []Alert {
var alerts []Alert
if service.numberOfFailuresInARow == 0 {
return alerts
}
for _, alert := range service.Alerts {
if alert.Enabled && alert.Threshold == service.numberOfFailuresInARow {
alerts = append(alerts, *alert)
continue
}
}
return alerts
}
func (service *Service) getIp(result *Result) { func (service *Service) getIp(result *Result) {
urlObject, err := url.Parse(service.Url) urlObject, err := url.Parse(service.Url)
if err != nil { if err != nil {

View File

@@ -13,6 +13,9 @@ const (
ResponseTimePlaceHolder = "[RESPONSE_TIME]" ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]" BodyPlaceHolder = "[BODY]"
LengthFunctionPrefix = "len("
FunctionSuffix = ")"
InvalidConditionElementSuffix = "(INVALID)" InvalidConditionElementSuffix = "(INVALID)"
) )
@@ -32,16 +35,25 @@ func sanitizeAndResolve(list []string, result *Result) []string {
element = body element = body
default: default:
// if starts with BodyPlaceHolder, then evaluate json path // if starts with BodyPlaceHolder, then evaluate json path
if strings.HasPrefix(element, BodyPlaceHolder) { if strings.Contains(element, BodyPlaceHolder) {
resolvedElement, err := jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceHolder), "", 1), result.Body) wantLength := false
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
wantLength = true
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
}
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceHolder), "", 1), result.Body)
if err != nil { if err != nil {
result.Errors = append(result.Errors, err.Error()) result.Errors = append(result.Errors, err.Error())
element = fmt.Sprintf("%s %s", element, InvalidConditionElementSuffix) element = fmt.Sprintf("%s %s", element, InvalidConditionElementSuffix)
} else {
if wantLength {
element = fmt.Sprintf("%d", resolvedElementLength)
} else { } else {
element = resolvedElement element = resolvedElement
} }
} }
} }
}
sanitizedList = append(sanitizedList, element) sanitizedList = append(sanitizedList, element)
} }
return sanitizedList return sanitizedList

View File

@@ -2,11 +2,12 @@ metrics: true
services: services:
- name: TwiNNatioN - name: TwiNNatioN
url: https://twinnation.org/health url: https://twinnation.org/health
interval: 10s interval: 30s
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: GitHub - name: GitHub
url: https://api.github.com/healthz url: https://api.github.com/healthz
interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: Example - name: Example

View File

@@ -10,6 +10,7 @@ data:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: GitHub - name: GitHub
url: https://api.github.com/healthz url: https://api.github.com/healthz
interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: Example - name: Example

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/TwinProduction/gatus module github.com/TwinProduction/gatus
go 1.14 go 1.15
require ( require (
github.com/prometheus/client_golang v1.2.1 github.com/prometheus/client_golang v1.2.1

View File

@@ -8,26 +8,30 @@ import (
) )
// Eval is a half-baked json path implementation that needs some love // Eval is a half-baked json path implementation that needs some love
func Eval(path string, b []byte) (string, error) { func Eval(path string, b []byte) (string, int, error) {
var object interface{} var object interface{}
err := json.Unmarshal(b, &object) err := json.Unmarshal(b, &object)
if err != nil { if err != nil {
// Try to unmarshal it into an array instead // Try to unmarshal it into an array instead
return "", err return "", 0, err
} }
return walk(path, object) return walk(path, object)
} }
func walk(path string, object interface{}) (string, error) { func walk(path string, object interface{}) (string, int, error) {
keys := strings.Split(path, ".") keys := strings.Split(path, ".")
currentKey := keys[0] currentKey := keys[0]
switch value := extractValue(currentKey, object).(type) { switch value := extractValue(currentKey, object).(type) {
case map[string]interface{}: case map[string]interface{}:
return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value) return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value)
case string:
return value, len(value), nil
case []interface{}:
return fmt.Sprintf("%v", value), len(value), nil
case interface{}: case interface{}:
return fmt.Sprintf("%v", value), nil return fmt.Sprintf("%v", value), 1, nil
default: default:
return "", fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value) return "", 0, fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value)
} }
} }

View File

@@ -8,10 +8,13 @@ func TestEval(t *testing.T) {
expectedOutput := "value" expectedOutput := "value"
output, err := Eval(path, []byte(data)) output, outputLength, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
if outputLength != len(expectedOutput) {
t.Errorf("Expected output length to be %v, but was %v", len(expectedOutput), outputLength)
}
if output != expectedOutput { if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
} }
@@ -23,7 +26,7 @@ func TestEvalWithLongSimpleWalk(t *testing.T) {
expectedOutput := "value" expectedOutput := "value"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
@@ -38,10 +41,11 @@ func TestEvalWithArrayOfMaps(t *testing.T) {
expectedOutput := "2" expectedOutput := "2"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
if output != expectedOutput { if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
} }
@@ -53,7 +57,7 @@ func TestEvalWithArrayOfValues(t *testing.T) {
expectedOutput := "1" expectedOutput := "1"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
@@ -68,7 +72,7 @@ func TestEvalWithRootArrayOfValues(t *testing.T) {
expectedOutput := "2" expectedOutput := "2"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
@@ -83,7 +87,7 @@ func TestEvalWithRootArrayOfMaps(t *testing.T) {
expectedOutput := "1" expectedOutput := "1"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
@@ -96,7 +100,7 @@ func TestEvalWithRootArrayOfMapsUsingInvalidArrayIndex(t *testing.T) {
path := "[5].id" path := "[5].id"
data := `[{"id": 1}, {"id": 2}]` data := `[{"id": 1}, {"id": 2}]`
_, err := Eval(path, []byte(data)) _, _, err := Eval(path, []byte(data))
if err == nil { if err == nil {
t.Error("Should've returned an error, but didn't") t.Error("Should've returned an error, but didn't")
} }
@@ -108,7 +112,7 @@ func TestEvalWithLongWalkAndArray(t *testing.T) {
expectedOutput := "1" expectedOutput := "1"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
@@ -123,7 +127,7 @@ func TestEvalWithNestedArray(t *testing.T) {
expectedOutput := "7" expectedOutput := "7"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }
@@ -138,7 +142,7 @@ func TestEvalWithMapOfNestedArray(t *testing.T) {
expectedOutput := "e" expectedOutput := "e"
output, err := Eval(path, []byte(data)) output, _, err := Eval(path, []byte(data))
if err != nil { if err != nil {
t.Error("Didn't expect any error, but got", err) t.Error("Didn't expect any error, but got", err)
} }

30
main.go
View File

@@ -1,6 +1,8 @@
package main package main
import ( import (
"bytes"
"compress/gzip"
"encoding/json" "encoding/json"
"github.com/TwinProduction/gatus/config" "github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/watchdog" "github.com/TwinProduction/gatus/watchdog"
@@ -8,6 +10,16 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time"
)
const CacheTTL = 10 * time.Second
var (
cachedServiceResults []byte
cachedServiceResultsGzipped []byte
cachedServiceResultsTimestamp time.Time
) )
func main() { func main() {
@@ -37,7 +49,10 @@ func loadConfiguration() *config.Config {
return config.Get() return config.Get()
} }
func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) { func serviceResultsHandler(writer http.ResponseWriter, r *http.Request) {
if isExpired := cachedServiceResultsTimestamp.IsZero() || time.Now().Sub(cachedServiceResultsTimestamp) > CacheTTL; isExpired {
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
serviceResults := watchdog.GetServiceResults() serviceResults := watchdog.GetServiceResults()
data, err := json.Marshal(serviceResults) data, err := json.Marshal(serviceResults)
if err != nil { if err != nil {
@@ -46,6 +61,19 @@ func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte("Unable to marshall object to JSON")) _, _ = writer.Write([]byte("Unable to marshall object to JSON"))
return return
} }
gzipWriter.Write(data)
gzipWriter.Close()
cachedServiceResults = data
cachedServiceResultsGzipped = buffer.Bytes()
cachedServiceResultsTimestamp = time.Now()
}
var data []byte
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
writer.Header().Set("Content-Encoding", "gzip")
data = cachedServiceResultsGzipped
} else {
data = cachedServiceResults
}
writer.Header().Add("Content-type", "application/json") writer.Header().Add("Content-type", "application/json")
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data) _, _ = writer.Write(data)

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -31,11 +31,18 @@
} }
.status { .status {
cursor: pointer; cursor: pointer;
transition: opacity 500ms ease-in-out; transition: all 500ms ease-in-out;
overflow-x: hidden;
padding: .25em 0;
color: white;
}
.title {
font-size: 2.5rem;
} }
.status:hover { .status:hover {
opacity: 0.7; opacity: 0.7;
transition: opacity 100ms ease-in-out; transition: opacity 100ms ease-in-out;
color: black;
} }
.status-over-time { .status-over-time {
overflow: auto; overflow: auto;
@@ -48,6 +55,9 @@
opacity: 0.5; opacity: 0.5;
margin-top: 5px; margin-top: 5px;
} }
.status-min-max-ms {
overflow-x: hidden;
}
#tooltip { #tooltip {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -76,9 +86,16 @@
</style> </style>
</head> </head>
<body> <body>
<div class="container my-3 rounded p-4 border shadow"> <div class="container my-3 rounded p-3 border shadow">
<div class="mb-3"> <div class="mb-2">
<div class="display-4">Health Status</div> <div class="row">
<div class="col-8 text-left my-auto">
<div class="title display-4">Health Status</div>
</div>
<div class="col-4 text-right">
<img src="logo.png" alt="GaTuS" style="position: relative; min-width: 50px; max-width: 200px; width: 20%;"/>
</div>
</div>
</div> </div>
<div id="results"></div> <div id="results"></div>
</div> </div>
@@ -136,6 +153,9 @@
let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect(); let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) { if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = element.getBoundingClientRect().x - tooltipBoundingClientRect.width + element.getBoundingClientRect().width; targetLeftPosition = element.getBoundingClientRect().x - tooltipBoundingClientRect.width + element.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
} }
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height) { if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height) {
targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10) targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10)
@@ -160,17 +180,25 @@
function refreshResults() { function refreshResults() {
$.getJSON("/api/v1/results", function (data) { $.getJSON("/api/v1/results", function (data) {
// Update the table only if there's a change
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
serviceStatuses = data; serviceStatuses = data;
buildTable();
}
});
}
function buildTable() {
let output = ""; let output = "";
for (let serviceName in data) { for (let serviceName in serviceStatuses) {
let serviceStatusOverTime = ""; let serviceStatusOverTime = "";
let hostname = data[serviceName][data[serviceName].length-1].hostname let hostname = serviceStatuses[serviceName][serviceStatuses[serviceName].length-1].hostname
let minResponseTime = null; let minResponseTime = null;
let maxResponseTime = null; let maxResponseTime = null;
let newestTimestamp = null; let newestTimestamp = null;
let oldestTimestamp = null; let oldestTimestamp = null;
for (let key in data[serviceName]) { for (let key in serviceStatuses[serviceName]) {
let serviceResult = data[serviceName][key]; let serviceResult = serviceStatuses[serviceName][key];
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime; serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000); const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) { if (minResponseTime == null || minResponseTime > responseTime) {
@@ -180,21 +208,21 @@
maxResponseTime = responseTime; maxResponseTime = responseTime;
} }
const timestamp = new Date(serviceResult.timestamp); const timestamp = new Date(serviceResult.timestamp);
if (newestTimestamp == null || newestTimestamp > timestamp) { if (newestTimestamp == null || newestTimestamp < timestamp) {
newestTimestamp = timestamp; newestTimestamp = timestamp;
} }
if (oldestTimestamp == null || oldestTimestamp < timestamp) { if (oldestTimestamp == null || oldestTimestamp > timestamp) {
oldestTimestamp = timestamp; oldestTimestamp = timestamp;
} }
} }
output += "" output += ""
+ "<div class='container py-3 border-left border-right border-top border-black'>" + "<div class='container py-3 border-left border-right border-top border-black'>"
+ " <div class='row mb-2'>" + " <div class='row mb-2'>"
+ " <div class='col-8'>" + " <div class='col-md-10'>"
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>" + " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>" + " </div>"
+ " <div class='col-4 text-right'>" + " <div class='col-md-2 text-right'>"
+ " <span class='font-weight-lighter'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>" + " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
+ " </div>" + " </div>"
+ " </div>" + " </div>"
+ " <div class='row'>" + " <div class='row'>"
@@ -204,16 +232,15 @@
+ " </div>" + " </div>"
+ " <div class='row status-time-ago'>" + " <div class='row status-time-ago'>"
+ " <div class='col-6'>" + " <div class='col-6'>"
+ " " + generatePrettyTimeAgo(newestTimestamp) + " " + generatePrettyTimeAgo(oldestTimestamp)
+ " </div>" + " </div>"
+ " <div class='col-6 text-right'>" + " <div class='col-6 text-right'>"
+ " " + generatePrettyTimeAgo(oldestTimestamp) + " " + generatePrettyTimeAgo(newestTimestamp)
+ " </div>" + " </div>"
+ " </div>" + " </div>"
+ "</div>"; + "</div>";
} }
$("#results").html(output); $("#results").html(output);
});
} }
function prettifyTimestamp(timestamp) { function prettifyTimestamp(timestamp) {

BIN
static/logo-256px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
static/logo-candidate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
static/logo-with-name.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -2,6 +2,7 @@ package watchdog
import ( import (
"fmt" "fmt"
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/config" "github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core" "github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/metric" "github.com/TwinProduction/gatus/metric"
@@ -15,10 +16,12 @@ var (
rwLock sync.RWMutex rwLock sync.RWMutex
) )
// GetServiceResults returns a list of the last 20 results for each services
func GetServiceResults() *map[string][]*core.Result { func GetServiceResults() *map[string][]*core.Result {
return &serviceResults return &serviceResults
} }
// Monitor loops over each services and starts a goroutine to monitor each services separately
func Monitor(cfg *config.Config) { func Monitor(cfg *config.Config) {
for _, service := range cfg.Services { for _, service := range cfg.Services {
go monitor(service) go monitor(service)
@@ -27,12 +30,13 @@ func Monitor(cfg *config.Config) {
} }
} }
// monitor monitors a single service in a loop
func monitor(service *core.Service) { func monitor(service *core.Service) {
for { for {
// By placing the lock here, we prevent multiple services from being monitored at the exact same time, which // By placing the lock here, we prevent multiple services from being monitored at the exact same time, which
// could cause performance issues and return inaccurate results // could cause performance issues and return inaccurate results
rwLock.Lock() rwLock.Lock()
log.Printf("[watchdog][Monitor] Monitoring serviceName=%s", service.Name) log.Printf("[watchdog][monitor] Monitoring serviceName=%s", service.Name)
result := service.EvaluateConditions() result := service.EvaluateConditions()
metric.PublishMetricsForService(service, result) metric.PublishMetricsForService(service, result)
serviceResults[service.Name] = append(serviceResults[service.Name], result) serviceResults[service.Name] = append(serviceResults[service.Name], result)
@@ -45,13 +49,28 @@ func monitor(service *core.Service) {
extra = fmt.Sprintf("responseBody=%s", result.Body) extra = fmt.Sprintf("responseBody=%s", result.Body)
} }
log.Printf( log.Printf(
"[watchdog][Monitor] Finished monitoring serviceName=%s; errors=%d; requestDuration=%s; %s", "[watchdog][monitor] Finished monitoring serviceName=%s; errors=%d; requestDuration=%s; %s",
service.Name, service.Name,
len(result.Errors), len(result.Errors),
result.Duration.Round(time.Millisecond), result.Duration.Round(time.Millisecond),
extra, extra,
) )
log.Printf("[watchdog][Monitor] Waiting interval=%s before monitoring serviceName=%s", service.Interval, service.Name)
cfg := config.Get()
if cfg.Alerting != nil {
for _, alertTriggered := range service.GetAlertsTriggered() {
if alertTriggered.Type == core.SlackAlert {
if len(cfg.Alerting.Slack) > 0 {
log.Printf("[watchdog][monitor] Sending Slack alert because alert with description=%s has been triggered", alertTriggered.Description)
alerting.SendSlackMessage(cfg.Alerting.Slack, service.Name, alertTriggered.Description)
} else {
log.Printf("[watchdog][monitor] Not sending Slack alert despite being triggered, because there is no Slack webhook configured")
}
}
}
}
log.Printf("[watchdog][monitor] Waiting for interval=%s before monitoring serviceName=%s", service.Interval, service.Name)
time.Sleep(service.Interval) time.Sleep(service.Interval)
} }
} }