Compare commits

..

36 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
TwinProduction
f50589e3c4 Add support for simple GraphQL requests 2020-07-24 16:45:51 -04:00
TwinProduction
98221626d3 Fix issue with json path when expected path doesn't match actual path 2020-07-24 13:20:28 -04:00
TwinProduction
60e30da7e5 Trim result body as well 2020-07-24 12:46:43 -04:00
TwinProduction
b05ae1c2d2 Minor improvement 2020-06-25 21:31:34 -04:00
TwinProduction
3b309500c3 Decrease tooltip fade timer from 2s to 500ms 2020-05-01 17:22:30 -04:00
TwinProduction
bf2fbcb395 Fix issue with scrollbar always showing 2020-04-26 23:59:30 -04:00
TwinProduction
fb90a0b299 Minor update 2020-04-22 23:54:30 -04:00
TwinProduction
72811311eb Remove popper.js dependency 2020-04-20 18:10:37 -04:00
TwinProduction
cc43fec366 Remove console logs 2020-04-20 17:58:51 -04:00
TwinProduction
eacd182302 Improve dashboard 2020-04-19 18:38:50 -04:00
TwinProduction
f8edf8862f Update README.md 2020-04-18 12:13:09 -04:00
TwinProduction
4d211a7288 Minor update 2020-04-15 21:55:10 -04:00
TwinProduction
18b8290e86 Minor update 2020-04-14 20:31:30 -04:00
TwinProduction
2878ad6a27 Improve documentation and panic on invalid service 2020-04-14 20:13:06 -04:00
TwinProduction
fe3e60dbd4 Add support for headers, method, body and json path with arrays 2020-04-14 19:20:00 -04:00
29 changed files with 1119 additions and 313 deletions

View File

@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2020 TwinProduction
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

116
README.md
View File

@@ -1,12 +1,14 @@
# gatus
![Gatus](static/logo-with-name.png)
![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)
A service health dashboard in Go that is meant to be used as a docker
image with a custom configuration file.
Live example: https://status.twinnation.org/
I personally deploy it in my Kubernetes cluster and have it monitor the status of my
core applications: https://status.twinnation.org/
## Usage
@@ -15,6 +17,8 @@ By default, the configuration file is expected to be at `config/config.yaml`.
You can specify a custom path by setting the `GATUS_CONFIG_FILE` environment variable.
Here's a simple example:
```yaml
metrics: true # Whether to expose metrics at /metrics
services:
@@ -23,13 +27,9 @@ services:
interval: 15s # Duration to wait between every status check (default: 10s)
conditions:
- "[STATUS] == 200" # Status must be 200
- "[BODY].status == UP" # The json path "$.status" must be equal to UP
- "[RESPONSE_TIME] < 300" # Response time must be under 300ms
- name: github
url: https://api.github.com/healthz
interval: 2m
conditions:
- "[STATUS] == 200"
- name: Example
- name: example
url: https://example.org/
interval: 30s
conditions:
@@ -39,19 +39,42 @@ services:
Note that you can also add environment variables in the your configuration file (i.e. `$DOMAIN`, `${DOMAIN}`)
### Configuration
| Parameter | Description | Default |
| --------------------------------- | --------------------------------------------------------------- | -------------- |
| `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[].url` | URL to send the request to | Required `""` |
| `services[].conditions` | Conditions used to determine the health of the service | `[]` |
| `services[].interval` | Duration to wait between every status check | `10s` |
| `services[].method` | Request method | `GET` |
| `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`) | `false` |
| `services[].body` | Request body | `""` |
| `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
Here are some examples of conditions you can use:
| Condition | Description | Passing values | Failing values |
| ------------------------------------- | ----------------------------------------- | ------------------------ | ----------------------- |
| `[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] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 |
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 200, 201, 300, 400 |
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 1500ms |
| `[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 |
| Condition | Description | Passing values | Failing values |
| -----------------------------| ------------------------------------------------------- | ------------------------ | ----------------------- |
| `[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] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 |
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 200, 201, 300, 400 |
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 1500ms |
| `[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[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.
@@ -81,3 +104,62 @@ go test ./... -mod vendor
## Using in Production
See the [example](example) folder.
## FAQ
### Sending a GraphQL request
By setting `services[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.
For instance, the following configuration:
```yaml
services:
- name: filter users by gender
url: http://localhost:8080/playground
method: POST
graphql: true
body: |
{
user(gender: "female") {
id
name
gender
avatar
}
}
headers:
Content-Type: application/json
conditions:
- "[STATUS] == 200"
- "[BODY].data.user[0].gender == female"
```
will send a `POST` request to `http://localhost:8080/playground` with the following body:
```json
{"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
}

19
client/client.go Normal file
View File

@@ -0,0 +1,19 @@
package client
import (
"net/http"
"time"
)
var (
client *http.Client
)
func GetHttpClient() *http.Client {
if client == nil {
client = &http.Client{
Timeout: time.Second * 10,
}
}
return client
}

View File

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

View File

@@ -7,13 +7,11 @@ import (
"io/ioutil"
"log"
"os"
"time"
)
type Config struct {
Metrics bool `yaml:"metrics"`
Services []*core.Service `yaml:"services"`
}
const (
DefaultConfigurationFilePath = "config/config.yaml"
)
var (
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
@@ -22,6 +20,12 @@ var (
config *Config
)
type Config struct {
Metrics bool `yaml:"metrics"`
Alerting *core.Alerting `yaml:"alerting"`
Services []*core.Service `yaml:"services"`
}
func Get() *Config {
if config == nil {
panic(ErrConfigNotLoaded)
@@ -44,7 +48,7 @@ func Load(configFile string) error {
}
func LoadDefaultConfiguration() error {
err := Load("config/config.yaml")
err := Load(DefaultConfigurationFilePath)
if err != nil {
if err == ErrConfigFileNotFound {
return Load("config/config.yml")
@@ -74,9 +78,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
} else {
// Set the default values if they aren't set
for _, service := range config.Services {
if service.Interval == 0 {
service.Interval = 10 * time.Second
}
service.Validate()
}
}
return

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"github.com/TwinProduction/gatus/core"
"testing"
"time"
)
@@ -23,6 +24,9 @@ services:
if err != nil {
t.Error("No error should've been returned")
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if len(config.Services) != 2 {
t.Error("Should have returned two services")
}
@@ -58,6 +62,9 @@ services:
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")
}
@@ -81,6 +88,9 @@ services:
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 have been true")
}
@@ -107,3 +117,62 @@ badconfig:
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"`
}

51
core/condition.go Normal file
View File

@@ -0,0 +1,51 @@
package core
import (
"fmt"
"log"
"strings"
)
type Condition string
func (c *Condition) evaluate(result *Result) bool {
condition := string(*c)
success := false
var resolvedCondition string
if strings.Contains(condition, "==") {
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = parts[0] == parts[1]
resolvedCondition = fmt.Sprintf("%v == %v", parts[0], parts[1])
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = parts[0] != parts[1]
resolvedCondition = fmt.Sprintf("%v != %v", parts[0], parts[1])
} else if strings.Contains(condition, "<=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = parts[0] <= parts[1]
resolvedCondition = fmt.Sprintf("%v <= %v", parts[0], parts[1])
} else if strings.Contains(condition, ">=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = parts[0] >= parts[1]
resolvedCondition = fmt.Sprintf("%v >= %v", parts[0], parts[1])
} else if strings.Contains(condition, ">") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = parts[0] > parts[1]
resolvedCondition = fmt.Sprintf("%v > %v", parts[0], parts[1])
} else if strings.Contains(condition, "<") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = parts[0] < parts[1]
resolvedCondition = fmt.Sprintf("%v < %v", parts[0], parts[1])
} else {
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
return false
}
conditionToDisplay := condition
// If the condition isn't a success, return what the resolved condition was too
if !success {
log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition)
conditionToDisplay = fmt.Sprintf("%s (%s)", condition, resolvedCondition)
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success
}

View File

@@ -5,7 +5,7 @@ import (
"time"
)
func TestEvaluateWithIp(t *testing.T) {
func TestCondition_evaluateWithIp(t *testing.T) {
condition := Condition("[IP] == 127.0.0.1")
result := &Result{Ip: "127.0.0.1"}
condition.evaluate(result)
@@ -14,7 +14,7 @@ func TestEvaluateWithIp(t *testing.T) {
}
}
func TestEvaluateWithStatus(t *testing.T) {
func TestCondition_evaluateWithStatus(t *testing.T) {
condition := Condition("[STATUS] == 201")
result := &Result{HttpStatus: 201}
condition.evaluate(result)
@@ -23,7 +23,7 @@ func TestEvaluateWithStatus(t *testing.T) {
}
}
func TestEvaluateWithStatusFailure(t *testing.T) {
func TestCondition_evaluateWithStatusFailure(t *testing.T) {
condition := Condition("[STATUS] == 200")
result := &Result{HttpStatus: 500}
condition.evaluate(result)
@@ -32,7 +32,7 @@ func TestEvaluateWithStatusFailure(t *testing.T) {
}
}
func TestEvaluateWithStatusUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithStatusUsingLessThan(t *testing.T) {
condition := Condition("[STATUS] < 300")
result := &Result{HttpStatus: 201}
condition.evaluate(result)
@@ -41,7 +41,7 @@ func TestEvaluateWithStatusUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithStatusFailureUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithStatusFailureUsingLessThan(t *testing.T) {
condition := Condition("[STATUS] < 300")
result := &Result{HttpStatus: 404}
condition.evaluate(result)
@@ -50,7 +50,7 @@ func TestEvaluateWithStatusFailureUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingLessThan(t *testing.T) {
condition := Condition("[RESPONSE_TIME] < 500")
result := &Result{Duration: time.Millisecond * 50}
condition.evaluate(result)
@@ -59,7 +59,7 @@ func TestEvaluateWithResponseTimeUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
condition := Condition("[RESPONSE_TIME] > 500")
result := &Result{Duration: time.Millisecond * 750}
condition.evaluate(result)
@@ -68,7 +68,7 @@ func TestEvaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
condition := Condition("[RESPONSE_TIME] >= 500")
result := &Result{Duration: time.Millisecond * 500}
condition.evaluate(result)
@@ -77,7 +77,7 @@ func TestEvaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
condition := Condition("[RESPONSE_TIME] <= 500")
result := &Result{Duration: time.Millisecond * 500}
condition.evaluate(result)
@@ -86,7 +86,7 @@ func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
}
}
func TestEvaluateWithBody(t *testing.T) {
func TestCondition_evaluateWithBody(t *testing.T) {
condition := Condition("[BODY] == test")
result := &Result{Body: []byte("test")}
condition.evaluate(result)
@@ -95,7 +95,7 @@ func TestEvaluateWithBody(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPath(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPath(t *testing.T) {
condition := Condition("[BODY].status == UP")
result := &Result{Body: []byte("{\"status\":\"UP\"}")}
condition.evaluate(result)
@@ -104,7 +104,7 @@ func TestEvaluateWithBodyJsonPath(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplex(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplex(t *testing.T) {
condition := Condition("[BODY].data.name == john")
result := &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")}
condition.evaluate(result)
@@ -113,7 +113,7 @@ func TestEvaluateWithBodyJsonPathComplex(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexInt(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathLongInt(t *testing.T) {
condition := Condition("[BODY].data.id == 1")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
@@ -122,7 +122,16 @@ func TestEvaluateWithBodyJsonPathComplexInt(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexInt(t *testing.T) {
condition := Condition("[BODY].data[1].id == 2")
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_evaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
condition := Condition("[BODY].data.id > 0")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
@@ -131,7 +140,7 @@ func TestEvaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T) {
condition := Condition("[BODY].data.id > 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
@@ -140,7 +149,7 @@ func TestEvaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T)
}
}
func TestEvaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
condition := Condition("[BODY].data.id < 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 2}}")}
condition.evaluate(result)
@@ -149,7 +158,7 @@ func TestEvaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
condition := Condition("[BODY].data.id < 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 10}}")}
condition.evaluate(result)
@@ -158,34 +167,20 @@ func TestEvaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
}
}
func TestIntegrationEvaluateConditions(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
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)
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
}
func TestIntegrationEvaluateConditionsWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if result.Success {
t.Error("Because one of the conditions failed, success should have been false")
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)
}
}

150
core/service.go Normal file
View File

@@ -0,0 +1,150 @@
package core
import (
"bytes"
"encoding/json"
"errors"
"github.com/TwinProduction/gatus/client"
"io/ioutil"
"net"
"net/http"
"net/url"
"time"
)
var (
ErrNoCondition = errors.New("you must specify at least one condition per service")
ErrNoUrl = errors.New("you must specify an url for each service")
)
type Service struct {
Name string `yaml:"name"`
Url string `yaml:"url"`
Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"`
GraphQL bool `yaml:"graphql,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Interval time.Duration `yaml:"interval,omitempty"`
Conditions []*Condition `yaml:"conditions"`
Alerts []*Alert `yaml:"alerts"`
numberOfFailuresInARow int
}
func (service *Service) Validate() {
// Set default values
if service.Interval == 0 {
service.Interval = 10 * time.Second
}
if len(service.Method) == 0 {
service.Method = http.MethodGet
}
if len(service.Headers) == 0 {
service.Headers = make(map[string]string)
}
for _, alert := range service.Alerts {
if alert.Threshold <= 0 {
alert.Threshold = 3
}
}
if len(service.Url) == 0 {
panic(ErrNoUrl)
}
if len(service.Conditions) == 0 {
panic(ErrNoCondition)
}
// Make sure that the request can be created
_, err := http.NewRequest(service.Method, service.Url, bytes.NewBuffer([]byte(service.Body)))
if err != nil {
panic(err)
}
}
func (service *Service) EvaluateConditions() *Result {
result := &Result{Success: true, Errors: []string{}}
service.getIp(result)
if len(result.Errors) == 0 {
service.call(result)
} else {
result.Success = false
}
for _, condition := range service.Conditions {
success := condition.evaluate(result)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
if result.Success {
service.numberOfFailuresInARow = 0
// TODO: Send notification that alert has been resolved?
} else {
service.numberOfFailuresInARow++
}
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) {
urlObject, err := url.Parse(service.Url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Hostname = urlObject.Hostname()
ips, err := net.LookupIP(urlObject.Hostname())
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Ip = ips[0].String()
}
func (service *Service) call(result *Result) {
request := service.buildRequest()
startTime := time.Now()
response, err := client.GetHttpClient().Do(request)
if err != nil {
result.Duration = time.Since(startTime)
result.Errors = append(result.Errors, err.Error())
return
}
result.Duration = time.Since(startTime)
result.HttpStatus = response.StatusCode
result.Body, err = ioutil.ReadAll(response.Body)
if err != nil {
result.Errors = append(result.Errors, err.Error())
}
}
func (service *Service) buildRequest() *http.Request {
var bodyBuffer *bytes.Buffer
if service.GraphQL {
graphQlBody := map[string]string{
"query": service.Body,
}
body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body)
} else {
bodyBuffer = bytes.NewBuffer([]byte(service.Body))
}
request, _ := http.NewRequest(service.Method, service.Url, bodyBuffer)
for k, v := range service.Headers {
request.Header.Set(k, v)
}
return request
}

37
core/service_test.go Normal file
View File

@@ -0,0 +1,37 @@
package core
import (
"testing"
)
func TestIntegrationEvaluateConditions(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
}
func TestIntegrationEvaluateConditionsWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if result.Success {
t.Error("Because one of the conditions failed, success should have been false")
}
}

View File

@@ -1,24 +1,9 @@
package core
import (
"fmt"
"github.com/TwinProduction/gatus/jsonpath"
"io/ioutil"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
StatusPlaceholder = "[STATUS]"
IPPlaceHolder = "[IP]"
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
)
type HealthStatus struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
@@ -36,135 +21,7 @@ type Result struct {
Timestamp time.Time `json:"timestamp"`
}
type Service struct {
Name string `yaml:"name"`
Url string `yaml:"url"`
Interval time.Duration `yaml:"interval,omitempty"`
Conditions []*Condition `yaml:"conditions"`
}
func (service *Service) getIp(result *Result) {
urlObject, err := url.Parse(service.Url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Hostname = urlObject.Hostname()
ips, err := net.LookupIP(urlObject.Hostname())
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Ip = ips[0].String()
}
func (service *Service) call(result *Result) {
// TODO: re-use the same client instead of creating multiple clients
client := &http.Client{
Timeout: time.Second * 10,
}
startTime := time.Now()
response, err := client.Get(service.Url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Duration = time.Now().Sub(startTime)
result.HttpStatus = response.StatusCode
result.Body, err = ioutil.ReadAll(response.Body)
if err != nil {
result.Errors = append(result.Errors, err.Error())
}
}
func (service *Service) EvaluateConditions() *Result {
result := &Result{Success: true, Errors: []string{}}
service.getIp(result)
if len(result.Errors) == 0 {
service.call(result)
} else {
result.Success = false
}
for _, condition := range service.Conditions {
success := condition.evaluate(result)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
return result
}
type ConditionResult struct {
Condition *Condition `json:"condition"`
Success bool `json:"success"`
}
type Condition string
func (c *Condition) evaluate(result *Result) bool {
condition := string(*c)
success := false
if strings.Contains(condition, "==") {
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = parts[0] == parts[1]
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = parts[0] != parts[1]
} else if strings.Contains(condition, "<=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = parts[0] <= parts[1]
} else if strings.Contains(condition, ">=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = parts[0] >= parts[1]
} else if strings.Contains(condition, ">") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = parts[0] > parts[1]
} else if strings.Contains(condition, "<") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = parts[0] < parts[1]
} else {
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
return false
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: c, Success: success})
return success
}
func sanitizeAndResolve(list []string, result *Result) []string {
var sanitizedList []string
for _, element := range list {
element = strings.TrimSpace(element)
switch strings.ToUpper(element) {
case StatusPlaceholder:
element = strconv.Itoa(result.HttpStatus)
case IPPlaceHolder:
element = result.Ip
case ResponseTimePlaceHolder:
element = strconv.Itoa(int(result.Duration.Milliseconds()))
case BodyPlaceHolder:
element = string(result.Body)
default:
// if starts with BodyPlaceHolder, then do the jsonpath thingy
if strings.HasPrefix(element, BodyPlaceHolder) {
element = jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceHolder), "", 1), result.Body)
}
}
sanitizedList = append(sanitizedList, element)
}
return sanitizedList
}
func sanitizeAndResolveNumerical(list []string, result *Result) []int {
var sanitizedNumbers []int
sanitizedList := sanitizeAndResolve(list, result)
for _, element := range sanitizedList {
if number, err := strconv.Atoi(element); err != nil {
// Default to 0 if the string couldn't be converted to an integer
sanitizedNumbers = append(sanitizedNumbers, 0)
} else {
sanitizedNumbers = append(sanitizedNumbers, number)
}
}
return sanitizedNumbers
Condition string `json:"condition"`
Success bool `json:"success"`
}

74
core/util.go Normal file
View File

@@ -0,0 +1,74 @@
package core
import (
"fmt"
"github.com/TwinProduction/gatus/jsonpath"
"strconv"
"strings"
)
const (
StatusPlaceholder = "[STATUS]"
IPPlaceHolder = "[IP]"
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
LengthFunctionPrefix = "len("
FunctionSuffix = ")"
InvalidConditionElementSuffix = "(INVALID)"
)
func sanitizeAndResolve(list []string, result *Result) []string {
var sanitizedList []string
body := strings.TrimSpace(string(result.Body))
for _, element := range list {
element = strings.TrimSpace(element)
switch strings.ToUpper(element) {
case StatusPlaceholder:
element = strconv.Itoa(result.HttpStatus)
case IPPlaceHolder:
element = result.Ip
case ResponseTimePlaceHolder:
element = strconv.Itoa(int(result.Duration.Milliseconds()))
case BodyPlaceHolder:
element = body
default:
// if starts with BodyPlaceHolder, then evaluate json path
if strings.Contains(element, BodyPlaceHolder) {
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 {
result.Errors = append(result.Errors, err.Error())
element = fmt.Sprintf("%s %s", element, InvalidConditionElementSuffix)
} else {
if wantLength {
element = fmt.Sprintf("%d", resolvedElementLength)
} else {
element = resolvedElement
}
}
}
}
sanitizedList = append(sanitizedList, element)
}
return sanitizedList
}
func sanitizeAndResolveNumerical(list []string, result *Result) []int {
var sanitizedNumbers []int
sanitizedList := sanitizeAndResolve(list, result)
for _, element := range sanitizedList {
if number, err := strconv.Atoi(element); err != nil {
// Default to 0 if the string couldn't be converted to an integer
sanitizedNumbers = append(sanitizedNumbers, 0)
} else {
sanitizedNumbers = append(sanitizedNumbers, number)
}
}
return sanitizedNumbers
}

View File

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

View File

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

2
go.mod
View File

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

View File

@@ -3,28 +3,72 @@ package jsonpath
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
func Eval(path string, b []byte) string {
var object map[string]interface{}
// Eval is a half-baked json path implementation that needs some love
func Eval(path string, b []byte) (string, int, error) {
var object interface{}
err := json.Unmarshal(b, &object)
if err != nil {
return ""
// Try to unmarshal it into an array instead
return "", 0, err
}
return walk(path, object)
}
func walk(path string, object map[string]interface{}) string {
func walk(path string, object interface{}) (string, int, error) {
keys := strings.Split(path, ".")
targetKey := keys[0]
// if there's only one key and the target key is that key, then return its value
if len(keys) == 1 {
return fmt.Sprintf("%v", object[targetKey])
currentKey := keys[0]
switch value := extractValue(currentKey, object).(type) {
case map[string]interface{}:
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{}:
return fmt.Sprintf("%v", value), 1, nil
default:
return "", 0, fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value)
}
// if there's more than one key, then walk deeper
if len(keys) > 0 {
return walk(strings.Replace(path, fmt.Sprintf("%s.", targetKey), "", 1), object[targetKey].(map[string]interface{}))
}
return ""
}
func extractValue(currentKey string, value interface{}) interface{} {
// Check if the current key ends with [#]
if strings.HasSuffix(currentKey, "]") && strings.Contains(currentKey, "[") {
tmp := strings.SplitN(currentKey, "[", 3)
arrayIndex, err := strconv.Atoi(strings.Replace(tmp[1], "]", "", 1))
if err != nil {
return value
}
currentKey := tmp[0]
// if currentKey contains only an index (i.e. [0] or 0)
if len(currentKey) == 0 {
array := value.([]interface{})
if len(array) > arrayIndex {
if len(tmp) > 2 {
// Nested array? Go deeper.
return extractValue(fmt.Sprintf("%s[%s", currentKey, tmp[2]), array[arrayIndex])
}
return array[arrayIndex]
}
return nil
}
if value == nil || value.(map[string]interface{})[currentKey] == nil {
return nil
}
// if currentKey contains both a key and an index (i.e. data[0])
array := value.(map[string]interface{})[currentKey].([]interface{})
if len(array) > arrayIndex {
if len(tmp) > 2 {
// Nested array? Go deeper.
return extractValue(fmt.Sprintf("[%s", tmp[2]), array[arrayIndex])
}
return array[arrayIndex]
}
return nil
}
return value.(map[string]interface{})[currentKey]
}

152
jsonpath/jsonpath_test.go Normal file
View File

@@ -0,0 +1,152 @@
package jsonpath
import "testing"
func TestEval(t *testing.T) {
path := "simple"
data := `{"simple": "value"}`
expectedOutput := "value"
output, outputLength, err := Eval(path, []byte(data))
if err != nil {
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 {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithLongSimpleWalk(t *testing.T) {
path := "long.simple.walk"
data := `{"long": {"simple": {"walk": "value"}}}`
expectedOutput := "value"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithArrayOfMaps(t *testing.T) {
path := "ids[1].id"
data := `{"ids": [{"id": 1}, {"id": 2}]}`
expectedOutput := "2"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithArrayOfValues(t *testing.T) {
path := "ids[0]"
data := `{"ids": [1, 2]}`
expectedOutput := "1"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithRootArrayOfValues(t *testing.T) {
path := "[1]"
data := `[1, 2]`
expectedOutput := "2"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithRootArrayOfMaps(t *testing.T) {
path := "[0].id"
data := `[{"id": 1}, {"id": 2}]`
expectedOutput := "1"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithRootArrayOfMapsUsingInvalidArrayIndex(t *testing.T) {
path := "[5].id"
data := `[{"id": 1}, {"id": 2}]`
_, _, err := Eval(path, []byte(data))
if err == nil {
t.Error("Should've returned an error, but didn't")
}
}
func TestEvalWithLongWalkAndArray(t *testing.T) {
path := "data.ids[0].id"
data := `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`
expectedOutput := "1"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithNestedArray(t *testing.T) {
path := "[3][2]"
data := `[[1, 2], [3, 4], [], [5, 6, 7]]`
expectedOutput := "7"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithMapOfNestedArray(t *testing.T) {
path := "data[1][1]"
data := `{"data": [["a", "b", "c"], ["d", "e", "f"]]}`
expectedOutput := "e"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}

54
main.go
View File

@@ -1,19 +1,29 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/watchdog"
"github.com/prometheus/client_golang/prometheus/promhttp"
"log"
"net/http"
"os"
"strings"
"time"
)
const CacheTTL = 10 * time.Second
var (
cachedServiceResults []byte
cachedServiceResultsGzipped []byte
cachedServiceResultsTimestamp time.Time
)
func main() {
cfg := loadConfiguration()
go watchdog.Monitor(cfg)
http.HandleFunc("/api/v1/results", serviceResultsHandler)
http.HandleFunc("/health", healthHandler)
http.Handle("/", http.FileServer(http.Dir("./static")))
@@ -21,6 +31,7 @@ func main() {
http.Handle("/metrics", promhttp.Handler())
}
log.Println("[main][main] Listening on port 8080")
go watchdog.Monitor(cfg)
log.Fatal(http.ListenAndServe(":8080", nil))
}
@@ -38,23 +49,38 @@ func loadConfiguration() *config.Config {
return config.Get()
}
func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) {
serviceResults := watchdog.GetServiceResults()
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()
data, err := json.Marshal(serviceResults)
if err != nil {
log.Printf("[main][serviceResultsHandler] Unable to marshall object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Unable to marshall object to JSON"))
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.WriteHeader(http.StatusOK)
_, _ = writer.Write(structToJsonBytes(serviceResults))
_, _ = writer.Write(data)
}
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Add("Content-type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(structToJsonBytes(&core.HealthStatus{Status: "UP"}))
}
func structToJsonBytes(obj interface{}) []byte {
bytes, err := json.Marshal(obj)
if err != nil {
log.Printf("[main][structToJsonBytes] Unable to marshall object to JSON: %s", err.Error())
}
return bytes
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,9 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Status</title>
<title>Health Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<style>
html, body {
background-color: #f7f9fb;
}
html {
height: 100%;
}
#results div.container:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
@@ -22,89 +29,220 @@
margin-right: 4px;
background-color: #28a745;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
padding: .25em 0;
color: white;
}
.title {
font-size: 2.5rem;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-over-time {
overflow: auto;
}
.status-over-time>span:not(:last-child) {
margin-left: 2px;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status-min-max-ms {
overflow-x: hidden;
}
#tooltip {
position: fixed;
top: 0;
left: 0;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip>.tooltip-title:first-child {
margin-top: 0;
}
</style>
</head>
<body>
<div class="container my-3 bg-light rounded p-4 border shadow">
<div class="text-center mb-3">
<div class="display-4">Status</div>
<div class="container my-3 rounded p-3 border shadow">
<div class="mb-2">
<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 id="results">
<div id="results"></div>
</div>
<div id="tooltip" style="display: none">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">...</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">...</code>
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">...</code>
<div id="tooltip-errors-container">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">...</code>
</div>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<script>
//const OK = "<div class='status-ok' title='__RESPONSE_TIME____CONDITIONS____ERRORS__'></div>"
const OK = "<span class='badge badge-success ml-1' style='width: 5%' title='__TIMESTAMP____RESPONSE_TIME____CONDITIONS____ERRORS__'>&#10003;</span>";
const NOK = "<span class='badge badge-danger ml-1' style='width: 5%' title='__TIMESTAMP____RESPONSE_TIME____CONDITIONS____ERRORS__'>X</span>";
let serviceStatuses = {};
let timerHandler = 0;
let userClickedStatus = false;
function generateServiceResultBox(serviceResult) {
let output = (serviceResult.success ? OK : NOK);
output = output.replace("__TIMESTAMP__", "Timestamp:\n" + prettifyTimestamp(serviceResult.timestamp));
output = output.replace("__RESPONSE_TIME__", "\n\nResponse time:\n" + parseInt(serviceResult.duration/1000000) + "ms");
function showTooltip(serviceName, index, element) {
userClickedStatus = false;
clearTimeout(timerHandler);
let serviceResult = serviceStatuses[serviceName][index];
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
// Populate the condition section
let conditions = "";
for (let conditionResultIndex in serviceResult['condition-results']) {
let conditionResult = serviceResult['condition-results'][conditionResultIndex];
conditions += "\n- " + (conditionResult.success ? "&#10003;" : "X") + " ~ " + conditionResult.condition;
for (let i in serviceResult['condition-results']) {
let conditionResult = serviceResult['condition-results'][i];
conditions += (conditionResult.success ? "&#10003;" : "X") + " ~ " + htmlEntities(conditionResult.condition) + "<br />";
}
output = output.replace("__CONDITIONS__", "\n\nConditions:" + conditions);
if (serviceResult['errors'].length > 0) {
$("#tooltip-conditions").html(conditions);
// Populate the error section only if there are errors
if (serviceResult.errors && serviceResult.errors.length > 0) {
let errors = "";
for (let errorIndex in serviceResult['errors']) {
errors += "\n- " + serviceResult['errors'][errorIndex];
for (let i in serviceResult.errors) {
errors += "- " + htmlEntities(serviceResult.errors[i]) + "<br />";
}
output = output.replace("__ERRORS__", "\n\nErrors: " + errors);
$("#tooltip-errors").html(errors);
$("#tooltip-errors-container").show();
} else {
output = output.replace("__ERRORS__", "");
$("#tooltip-errors-container").hide();
}
return output;
// Position tooltip
$("#tooltip").css({top: "0px", left: "0px"}).show();
let targetTopPosition = element.getBoundingClientRect().y + 30;
let targetLeftPosition = element.getBoundingClientRect().x;
// Make adjustments if necessary
let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.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) {
targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10)
}
$("#tooltip").css({top: targetTopPosition + "px", left: targetLeftPosition + "px"});
}
function fadeTooltip() {
if (!userClickedStatus) {
timerHandler = setTimeout(function () {
$("#tooltip").hide();
}, 500);
}
}
function createStatusBadge(serviceName, index, success) {
if (success) {
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\""+serviceName+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>&#10003;</span>";
}
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\""+serviceName+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
}
function refreshResults() {
$.getJSON("/api/v1/results", function (data) {
let output = "";
for (let serviceName in data) {
let serviceStatusOverTime = "";
let hostname = data[serviceName][data[serviceName].length-1].hostname
let minResponseTime = null;
let maxResponseTime = null;
for (let key in data[serviceName]) {
let serviceResult = data[serviceName][key];
console.log(serviceResult);
serviceStatusOverTime = generateServiceResultBox(serviceResult) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
output += ""
+ "<div class='container p-2 border-left border-right border-top border-black'>"
+ " <div class='row mb-2'>"
+ " <div class='col-8'>"
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>"
+ " <div class='col-4 text-right'>"
+ " <span class='font-weight-lighter'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
+ " </div>"
+ " </div>"
+ " <div class='row'>"
+ " <div class='col-12 d-flex flex-row-reverse'>"
+ " " + serviceStatusOverTime
+ " </div>"
+ " </div>"
+ "</div>";
// Update the table only if there's a change
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
serviceStatuses = data;
buildTable();
}
$("#results").html(output);
});
}
function buildTable() {
let output = "";
for (let serviceName in serviceStatuses) {
let serviceStatusOverTime = "";
let hostname = serviceStatuses[serviceName][serviceStatuses[serviceName].length-1].hostname
let minResponseTime = null;
let maxResponseTime = null;
let newestTimestamp = null;
let oldestTimestamp = null;
for (let key in serviceStatuses[serviceName]) {
let serviceResult = serviceStatuses[serviceName][key];
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
const timestamp = new Date(serviceResult.timestamp);
if (newestTimestamp == null || newestTimestamp < timestamp) {
newestTimestamp = timestamp;
}
if (oldestTimestamp == null || oldestTimestamp > timestamp) {
oldestTimestamp = timestamp;
}
}
output += ""
+ "<div class='container py-3 border-left border-right border-top border-black'>"
+ " <div class='row mb-2'>"
+ " <div class='col-md-10'>"
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>"
+ " <div class='col-md-2 text-right'>"
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
+ " </div>"
+ " </div>"
+ " <div class='row'>"
+ " <div class='col-12 d-flex flex-row-reverse status-over-time'>"
+ " " + serviceStatusOverTime
+ " </div>"
+ " </div>"
+ " <div class='row status-time-ago'>"
+ " <div class='col-6'>"
+ " " + generatePrettyTimeAgo(oldestTimestamp)
+ " </div>"
+ " <div class='col-6 text-right'>"
+ " " + generatePrettyTimeAgo(newestTimestamp)
+ " </div>"
+ " </div>"
+ "</div>";
}
$("#results").html(output);
}
function prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
@@ -113,13 +251,35 @@
let hh = ((date.getHours())<10?"0":"")+""+(date.getHours());
let mm = ((date.getMinutes())<10?"0":"")+""+(date.getMinutes());
let ss = ((date.getSeconds())<10?"0":"")+""+(date.getSeconds());
return YYYY+"-"+MM+"-"+DD+" "+hh+":"+mm+":"+ss;
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
}
function generatePrettyTimeAgo(t) {
let differenceInMs = new Date().getTime() - new Date(t).getTime();
if (differenceInMs > 3600000) {
let hours = (differenceInMs/3600000).toFixed(0);
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 60000) {
let minutes = (differenceInMs/60000).toFixed(0);
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
}
return (differenceInMs/1000).toFixed(0) + " seconds ago";
}
function htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
refreshResults();
setInterval(function() {
refreshResults();
}, 10000);
}, 30000);
</script>
</body>
</html>

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

@@ -1,6 +1,8 @@
package watchdog
import (
"fmt"
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/metric"
@@ -14,24 +16,27 @@ var (
rwLock sync.RWMutex
)
// GetServiceResults returns a list of the last 20 results for each services
func GetServiceResults() *map[string][]*core.Result {
return &serviceResults
}
// Monitor loops over each services and starts a goroutine to monitor each services separately
func Monitor(cfg *config.Config) {
for _, service := range cfg.Services {
go monitor(service)
// To prevent multiple requests from running at the same time
time.Sleep(500 * time.Millisecond)
time.Sleep(1111 * time.Millisecond)
}
}
// monitor monitors a single service in a loop
func monitor(service *core.Service) {
for {
// 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
rwLock.Lock()
log.Printf("[watchdog][Monitor] Monitoring serviceName=%s", service.Name)
log.Printf("[watchdog][monitor] Monitoring serviceName=%s", service.Name)
result := service.EvaluateConditions()
metric.PublishMetricsForService(service, result)
serviceResults[service.Name] = append(serviceResults[service.Name], result)
@@ -39,13 +44,33 @@ func monitor(service *core.Service) {
serviceResults[service.Name] = serviceResults[service.Name][1:]
}
rwLock.Unlock()
var extra string
if !result.Success {
extra = fmt.Sprintf("responseBody=%s", result.Body)
}
log.Printf(
"[watchdog][Monitor] Finished monitoring serviceName=%s; errors=%d; requestDuration=%s",
"[watchdog][monitor] Finished monitoring serviceName=%s; errors=%d; requestDuration=%s; %s",
service.Name,
len(result.Errors),
result.Duration.Round(time.Millisecond),
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)
}
}