Compare commits

...

14 Commits

Author SHA1 Message Date
TwinProduction
88d0d8a724 Fix table format 2020-04-12 19:54:33 -04:00
TwinProduction
5803dca3a5 Minor update 2020-04-12 19:51:28 -04:00
TwinProduction
f98c60467d Minimize JSON data 2020-04-12 19:48:35 -04:00
TwinProduction
c51f35bcea Minor update 2020-04-11 23:17:31 -04:00
TwinProduction
02098a9742 Minor update 2020-04-11 23:14:20 -04:00
TwinProduction
3e2b56ba89 Add support for [BODY] placeholder and basic JSON path support
Note that arrays are not currently supported, same with asterisks
2020-04-10 22:56:38 -04:00
TwinProduction
15b8f8a293 Add build badge 2020-04-10 17:19:59 -04:00
TwinProduction
58327394dd Update build job 2020-04-10 16:50:29 -04:00
TwinProduction
49f5940a43 Modify example config 2020-04-10 16:46:19 -04:00
TwinProduction
1f177902e6 Remove Explanation field from ConditionResult 2020-04-10 16:37:52 -04:00
TwinProduction
6888ced1a7 Remove duplicate __TIMESTAMP__ 2020-04-10 16:37:11 -04:00
TwinProduction
cc159fa8fb Update to Go 1.14 2020-04-10 16:35:55 -04:00
TwinProduction
92cb9c86d1 Add support for [RESPONSE_TIME] and >, <, <=, >= operators 2020-04-10 16:34:20 -04:00
TwinProduction
1701ced07b Add timestamp in hover text 2020-04-07 18:23:26 -04:00
10 changed files with 299 additions and 90 deletions

27
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: build
on:
pull_request:
paths-ignore:
- '*.md'
push:
branches:
- master
paths-ignore:
- '*.md'
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.14
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Build binary to make sure it works
run: go build -mod vendor
- name: Test
run: go test -mod vendor -cover ./...

View File

@@ -1,34 +0,0 @@
name: Go
on: [push]
jobs:
build-112:
name: Build with Go 1.12
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.12
uses: actions/setup-go@v1
with:
go-version: 1.12
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Test
run: go test -mod vendor ./...
- name: Build
run: go build -mod vendor
build-113:
name: Build with Go 1.13
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.13
uses: actions/setup-go@v1
with:
go-version: 1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Test
run: go test -mod vendor ./...
- name: Build
run: go build -mod vendor

View File

@@ -1,5 +1,6 @@
# gatus
![build](https://github.com/TwinProduction/gatus/workflows/build/badge.svg?branch=master)
[![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
@@ -19,11 +20,18 @@ metrics: true # Whether to expose metrics at /metrics
services:
- name: twinnation # Name of your service, can be anything
url: https://twinnation.org/health
interval: 15s # Duration to wait between every status check (opt. default: 10s)
interval: 15s # Duration to wait between every status check (default: 10s)
conditions:
- "[STATUS] == 200"
- "[STATUS] == 200" # Status must be 200
- "[RESPONSE_TIME] < 300" # Response time must be under 300ms
- name: github
url: https://api.github.com/healthz
interval: 2m
conditions:
- "[STATUS] == 200"
- name: Example
url: https://example.org/
interval: 30s
conditions:
- "[STATUS] == 200"
```
@@ -31,6 +39,23 @@ services:
Note that you can also add environment variables in the your configuration file (i.e. `$DOMAIN`, `${DOMAIN}`)
### 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 |
**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.
## Docker
Building the Docker image is done as following:

View File

@@ -2,11 +2,13 @@ metrics: true
services:
- name: Twinnation
url: https://twinnation.org/health
interval: 30s
interval: 10s
conditions:
- "[STATUS] == 200"
- name: GitHub API
url: https://api.github.com/healthz
- "[RESPONSE_TIME] < 500"
- "[BODY].status == UP"
- name: Example
url: https://example.org/
interval: 30s
conditions:
- "[STATUS] == 200"

View File

@@ -2,6 +2,8 @@ package core
import (
"fmt"
"github.com/TwinProduction/gatus/jsonpath"
"io/ioutil"
"net"
"net/http"
"net/url"
@@ -10,18 +12,21 @@ import (
"time"
)
const (
StatusPlaceholder = "[STATUS]"
IPPlaceHolder = "[IP]"
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
)
type HealthStatus struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
type ServerMessage struct {
Error bool `json:"error"`
Message string `json:"message"`
}
type Result struct {
HttpStatus int `json:"status"`
Body []byte `json:"-"`
Hostname string `json:"hostname"`
Ip string `json:"-"`
Duration time.Duration `json:"duration"`
@@ -53,7 +58,8 @@ func (service *Service) getIp(result *Result) {
result.Ip = ips[0].String()
}
func (service *Service) getStatus(result *Result) {
func (service *Service) call(result *Result) {
// TODO: re-use the same client instead of creating multiple clients
client := &http.Client{
Timeout: time.Second * 10,
}
@@ -65,13 +71,17 @@ func (service *Service) getStatus(result *Result) {
}
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.getStatus(result)
service.call(result)
} else {
result.Success = false
}
@@ -86,53 +96,39 @@ func (service *Service) EvaluateConditions() *Result {
}
type ConditionResult struct {
Condition *Condition `json:"condition"`
Success bool `json:"success"`
Explanation string `json:"-"`
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)
if parts[0] == parts[1] {
result.ConditionResults = append(result.ConditionResults, &ConditionResult{
Condition: c,
Success: true,
Explanation: fmt.Sprintf("%s is equal to %s", parts[0], parts[1]),
})
return true
} else {
result.ConditionResults = append(result.ConditionResults, &ConditionResult{
Condition: c,
Success: false,
Explanation: fmt.Sprintf("%s is not equal to %s", parts[0], parts[1]),
})
return false
}
success = parts[0] == parts[1]
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
if parts[0] != parts[1] {
result.ConditionResults = append(result.ConditionResults, &ConditionResult{
Condition: c,
Success: true,
Explanation: fmt.Sprintf("%s is not equal to %s", parts[0], parts[1]),
})
return true
} else {
result.ConditionResults = append(result.ConditionResults, &ConditionResult{
Condition: c,
Success: false,
Explanation: fmt.Sprintf("%s is equal to %s", parts[0], parts[1]),
})
return false
}
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 {
@@ -140,13 +136,35 @@ func sanitizeAndResolve(list []string, result *Result) []string {
for _, element := range list {
element = strings.TrimSpace(element)
switch strings.ToUpper(element) {
case "[STATUS]":
case StatusPlaceholder:
element = strconv.Itoa(result.HttpStatus)
case "[IP]":
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
}

View File

@@ -2,6 +2,7 @@ package core
import (
"testing"
"time"
)
func TestEvaluateWithIp(t *testing.T) {
@@ -22,7 +23,7 @@ func TestEvaluateWithStatus(t *testing.T) {
}
}
func TestEvaluateWithFailure(t *testing.T) {
func TestEvaluateWithStatusFailure(t *testing.T) {
condition := Condition("[STATUS] == 200")
result := &Result{HttpStatus: 500}
condition.evaluate(result)
@@ -31,11 +32,137 @@ func TestEvaluateWithFailure(t *testing.T) {
}
}
func TestEvaluateWithStatusUsingLessThan(t *testing.T) {
condition := Condition("[STATUS] < 300")
result := &Result{HttpStatus: 201}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithStatusFailureUsingLessThan(t *testing.T) {
condition := Condition("[STATUS] < 300")
result := &Result{HttpStatus: 404}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestEvaluateWithResponseTimeUsingLessThan(t *testing.T) {
condition := Condition("[RESPONSE_TIME] < 500")
result := &Result{Duration: time.Millisecond * 50}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
condition := Condition("[RESPONSE_TIME] > 500")
result := &Result{Duration: time.Millisecond * 750}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
condition := Condition("[RESPONSE_TIME] >= 500")
result := &Result{Duration: time.Millisecond * 500}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
condition := Condition("[RESPONSE_TIME] <= 500")
result := &Result{Duration: time.Millisecond * 500}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBody(t *testing.T) {
condition := Condition("[BODY] == test")
result := &Result{Body: []byte("test")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBodyJsonPath(t *testing.T) {
condition := Condition("[BODY].status == UP")
result := &Result{Body: []byte("{\"status\":\"UP\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBodyJsonPathComplex(t *testing.T) {
condition := Condition("[BODY].data.name == john")
result := &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBodyJsonPathComplexInt(t *testing.T) {
condition := Condition("[BODY].data.id == 1")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
condition := Condition("[BODY].data.id > 0")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T) {
condition := Condition("[BODY].data.id > 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestEvaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
condition := Condition("[BODY].data.id < 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 2}}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestEvaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
condition := Condition("[BODY].data.id < 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 10}}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestIntegrationEvaluateConditions(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "GitHub",
Url: "https://api.github.com/healthz",
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
@@ -50,8 +177,8 @@ func TestIntegrationEvaluateConditions(t *testing.T) {
func TestIntegrationEvaluateConditionsWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "GitHub",
Url: "https://api.github.com/healthz",
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()

2
go.mod
View File

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

30
jsonpath/jsonpath.go Normal file
View File

@@ -0,0 +1,30 @@
package jsonpath
import (
"encoding/json"
"fmt"
"strings"
)
func Eval(path string, b []byte) string {
var object map[string]interface{}
err := json.Unmarshal(b, &object)
if err != nil {
return ""
}
return walk(path, object)
}
func walk(path string, object map[string]interface{}) string {
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])
}
// 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 ""
}

View File

@@ -39,12 +39,13 @@
<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='__RESPONSE_TIME____CONDITIONS____ERRORS__'>&#10003;</span>";
const NOK = "<span class='badge badge-danger ml-1' style='width: 5%' title='__RESPONSE_TIME____CONDITIONS____ERRORS__'>X</span>";
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>";
function generateServiceResultBox(serviceResult) {
let output = (serviceResult.success ? OK : NOK);
output = output.replace("__RESPONSE_TIME__", "Response time:\n" + parseInt(serviceResult.duration/1000000) + "ms");
output = output.replace("__TIMESTAMP__", "Timestamp:\n" + prettifyTimestamp(serviceResult.timestamp));
output = output.replace("__RESPONSE_TIME__", "\n\nResponse time:\n" + parseInt(serviceResult.duration/1000000) + "ms");
let conditions = "";
for (let conditionResultIndex in serviceResult['condition-results']) {
let conditionResult = serviceResult['condition-results'][conditionResultIndex];
@@ -104,6 +105,17 @@
});
}
function prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth()+1)<10?"0":"")+""+(date.getMonth()+1);
let DD = ((date.getDate())<10?"0":"")+""+(date.getDate());
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;
}
refreshResults();
setInterval(function() {
refreshResults();

2
vendor/modules.txt vendored
View File

@@ -7,6 +7,7 @@ github.com/golang/protobuf/proto
# github.com/matttproud/golang_protobuf_extensions v1.0.1
github.com/matttproud/golang_protobuf_extensions/pbutil
# github.com/prometheus/client_golang v1.2.1
## explicit
github.com/prometheus/client_golang/prometheus
github.com/prometheus/client_golang/prometheus/internal
github.com/prometheus/client_golang/prometheus/promauto
@@ -24,4 +25,5 @@ github.com/prometheus/procfs/internal/util
# golang.org/x/sys v0.0.0-20191010194322-b09406accb47
golang.org/x/sys/windows
# gopkg.in/yaml.v2 v2.2.2
## explicit
gopkg.in/yaml.v2