Compare commits

...

18 Commits

Author SHA1 Message Date
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
16 changed files with 819 additions and 278 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.

View File

@@ -6,7 +6,8 @@
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 +16,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 +26,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 +38,37 @@ 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` |
| `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 | `{}` |
### 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 +98,38 @@ 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:
```
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 }"}
```

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
url: https://twinnation.org/health
- name: twinnation
interval: 10s
url: https://twinnation.org/health
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 500"
- "[BODY].status == UP"
- name: Example
url: https://example.org/
interval: 30s
- "[RESPONSE_TIME] < 1000"
- name: twinnation-articles-api
interval: 10s
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,11 @@ var (
config *Config
)
type Config struct {
Metrics bool `yaml:"metrics"`
Services []*core.Service `yaml:"services"`
}
func Get() *Config {
if config == nil {
panic(ErrConfigNotLoaded)
@@ -44,7 +47,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 +77,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

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)
}
}

122
core/service.go Normal file
View File

@@ -0,0 +1,122 @@
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"`
}
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)
}
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()
return result
}
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"`
}

71
core/util.go Normal file
View File

@@ -0,0 +1,71 @@
package core
import (
"fmt"
"github.com/TwinProduction/gatus/jsonpath"
"strconv"
"strings"
)
const (
StatusPlaceholder = "[STATUS]"
IPPlaceHolder = "[IP]"
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
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, "len(") && strings.HasSuffix(element, ")") {
wantLength = true
element = strings.TrimSuffix(strings.TrimPrefix(element, "len("), ")")
}
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

@@ -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)
}
}

22
main.go
View File

@@ -3,7 +3,6 @@ package main
import (
"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"
@@ -13,7 +12,6 @@ import (
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 +19,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))
}
@@ -40,21 +39,20 @@ func loadConfiguration() *config.Config {
func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) {
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
}
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\"}"))
}

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,60 +29,149 @@
margin-right: 4px;
background-color: #28a745;
}
.status {
cursor: pointer;
transition: opacity 500ms ease-in-out;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
}
.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;
}
#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-4 border shadow">
<div class="mb-3">
<div class="display-4">Health Status</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 (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) {
serviceStatuses = data;
let output = "";
for (let serviceName in data) {
let serviceStatusOverTime = "";
let hostname = data[serviceName][data[serviceName].length-1].hostname
let hostname = data[serviceName][data[serviceName].length-1].hostname
let minResponseTime = null;
let maxResponseTime = null;
let newestTimestamp = null;
let oldestTimestamp = null;
for (let key in data[serviceName]) {
let serviceResult = data[serviceName][key];
console.log(serviceResult);
serviceStatusOverTime = generateServiceResultBox(serviceResult) + serviceStatusOverTime;
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
@@ -83,22 +179,37 @@
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 p-2 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='col-8'>"
+ " <div class='col-10'>"
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>"
+ " <div class='col-4 text-right'>"
+ " <div class='col-2 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'>"
+ " <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(newestTimestamp)
+ " </div>"
+ " <div class='col-6 text-right'>"
+ " " + generatePrettyTimeAgo(oldestTimestamp)
+ " </div>"
+ " </div>"
+ "</div>";
}
$("#results").html(output);
@@ -116,10 +227,32 @@
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>

View File

@@ -1,6 +1,7 @@
package watchdog
import (
"fmt"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/metric"
@@ -22,7 +23,7 @@ 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)
}
}
@@ -39,11 +40,16 @@ 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)
time.Sleep(service.Interval)