Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72811311eb | ||
|
|
cc43fec366 | ||
|
|
eacd182302 | ||
|
|
f8edf8862f | ||
|
|
4d211a7288 | ||
|
|
18b8290e86 | ||
|
|
2878ad6a27 | ||
|
|
fe3e60dbd4 | ||
|
|
88d0d8a724 | ||
|
|
5803dca3a5 | ||
|
|
f98c60467d | ||
|
|
c51f35bcea | ||
|
|
02098a9742 | ||
|
|
3e2b56ba89 | ||
|
|
15b8f8a293 | ||
|
|
58327394dd | ||
|
|
49f5940a43 | ||
|
|
1f177902e6 | ||
|
|
6888ced1a7 | ||
|
|
cc159fa8fb | ||
|
|
92cb9c86d1 | ||
|
|
1701ced07b |
27
.github/workflows/build.yml
vendored
Normal file
27
.github/workflows/build.yml
vendored
Normal 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 ./...
|
||||||
34
.github/workflows/go.yml
vendored
34
.github/workflows/go.yml
vendored
@@ -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
|
|
||||||
49
README.md
49
README.md
@@ -1,11 +1,13 @@
|
|||||||
# gatus
|
# gatus
|
||||||
|
|
||||||
|

|
||||||
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
|
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
|
||||||
|
|
||||||
A service health dashboard in Go that is meant to be used as a docker
|
A service health dashboard in Go that is meant to be used as a docker
|
||||||
image with a custom configuration file.
|
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
|
## Usage
|
||||||
@@ -14,16 +16,21 @@ 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.
|
You can specify a custom path by setting the `GATUS_CONFIG_FILE` environment variable.
|
||||||
|
|
||||||
|
Here's a simple example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
metrics: true # Whether to expose metrics at /metrics
|
metrics: true # Whether to expose metrics at /metrics
|
||||||
services:
|
services:
|
||||||
- name: twinnation # Name of your service, can be anything
|
- name: twinnation # Name of your service, can be anything
|
||||||
url: https://twinnation.org/health
|
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:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200" # Status must be 200
|
||||||
- name: github
|
- "[BODY].status == UP" # The json path "$.status" must be equal to UP
|
||||||
url: https://api.github.com/healthz
|
- "[RESPONSE_TIME] < 300" # Response time must be under 300ms
|
||||||
|
- name: example
|
||||||
|
url: https://example.org/
|
||||||
|
interval: 30s
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
```
|
```
|
||||||
@@ -31,6 +38,38 @@ services:
|
|||||||
Note that you can also add environment variables in the your configuration file (i.e. `$DOMAIN`, `${DOMAIN}`)
|
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[].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 |
|
||||||
|
| `[BODY].data[0].id == 1` | The jsonpath `$.data[0].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
|
## Docker
|
||||||
|
|
||||||
Building the Docker image is done as following:
|
Building the Docker image is done as following:
|
||||||
|
|||||||
19
client/client.go
Normal file
19
client/client.go
Normal 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
|
||||||
|
}
|
||||||
21
config.yaml
21
config.yaml
@@ -1,12 +1,21 @@
|
|||||||
metrics: true
|
metrics: true
|
||||||
services:
|
services:
|
||||||
- name: Twinnation
|
- name: twinnation
|
||||||
|
interval: 10s
|
||||||
url: https://twinnation.org/health
|
url: https://twinnation.org/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY].status == UP"
|
||||||
|
- "[RESPONSE_TIME] < 1000"
|
||||||
|
- name: twinnation-articles-api
|
||||||
|
interval: 10s
|
||||||
|
url: https://twinnation.org/api/v1/articles/24
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY].id == 24"
|
||||||
|
- "[BODY].tags[0] == spring"
|
||||||
|
- name: example
|
||||||
|
url: https://example.org/
|
||||||
interval: 30s
|
interval: 30s
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- name: GitHub API
|
|
||||||
url: https://api.github.com/healthz
|
|
||||||
interval: 30s
|
|
||||||
conditions:
|
|
||||||
- "[STATUS] == 200"
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -74,9 +73,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
|||||||
} else {
|
} else {
|
||||||
// Set the default values if they aren't set
|
// Set the default values if they aren't set
|
||||||
for _, service := range config.Services {
|
for _, service := range config.Services {
|
||||||
if service.Interval == 0 {
|
service.Validate()
|
||||||
service.Interval = 10 * time.Second
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
51
core/condition.go
Normal file
51
core/condition.go
Normal 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 the resolved condition
|
||||||
|
if !success {
|
||||||
|
log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition)
|
||||||
|
conditionToDisplay = resolvedCondition
|
||||||
|
}
|
||||||
|
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
|
||||||
|
return success
|
||||||
|
}
|
||||||
168
core/condition_test.go
Normal file
168
core/condition_test.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCondition_evaluateWithIp(t *testing.T) {
|
||||||
|
condition := Condition("[IP] == 127.0.0.1")
|
||||||
|
result := &Result{Ip: "127.0.0.1"}
|
||||||
|
condition.evaluate(result)
|
||||||
|
if !result.ConditionResults[0].Success {
|
||||||
|
t.Errorf("Condition '%s' should have been a success", condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCondition_evaluateWithStatus(t *testing.T) {
|
||||||
|
condition := Condition("[STATUS] == 201")
|
||||||
|
result := &Result{HttpStatus: 201}
|
||||||
|
condition.evaluate(result)
|
||||||
|
if !result.ConditionResults[0].Success {
|
||||||
|
t.Errorf("Condition '%s' should have been a success", condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCondition_evaluateWithStatusFailure(t *testing.T) {
|
||||||
|
condition := Condition("[STATUS] == 200")
|
||||||
|
result := &Result{HttpStatus: 500}
|
||||||
|
condition.evaluate(result)
|
||||||
|
if result.ConditionResults[0].Success {
|
||||||
|
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCondition_evaluateWithStatusUsingLessThan(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 TestCondition_evaluateWithStatusFailureUsingLessThan(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 TestCondition_evaluateWithResponseTimeUsingLessThan(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 TestCondition_evaluateWithResponseTimeUsingGreaterThan(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 TestCondition_evaluateWithResponseTimeUsingGreaterThanOrEqualTo(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 TestCondition_evaluateWithResponseTimeUsingLessThanOrEqualTo(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 TestCondition_evaluateWithBody(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 TestCondition_evaluateWithBodyJsonPath(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 TestCondition_evaluateWithBodyJsonPathComplex(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 TestCondition_evaluateWithBodyJsonPathLongInt(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 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)
|
||||||
|
if !result.ConditionResults[0].Success {
|
||||||
|
t.Errorf("Condition '%s' should have been a success", condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(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 TestCondition_evaluateWithBodyJsonPathComplexIntUsingLessThan(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 TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingLessThan(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
core/service.go
Normal file
110
core/service.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"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"`
|
||||||
|
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 {
|
||||||
|
request, _ := http.NewRequest(service.Method, service.Url, bytes.NewBuffer([]byte(service.Body)))
|
||||||
|
for k, v := range service.Headers {
|
||||||
|
request.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
37
core/service_test.go
Normal file
37
core/service_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
131
core/types.go
131
core/types.go
@@ -1,12 +1,6 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,13 +9,9 @@ type HealthStatus struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerMessage struct {
|
|
||||||
Error bool `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
HttpStatus int `json:"status"`
|
HttpStatus int `json:"status"`
|
||||||
|
Body []byte `json:"-"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Ip string `json:"-"`
|
Ip string `json:"-"`
|
||||||
Duration time.Duration `json:"duration"`
|
Duration time.Duration `json:"duration"`
|
||||||
@@ -31,122 +21,7 @@ type Result struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
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) getStatus(result *Result) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (service *Service) EvaluateConditions() *Result {
|
|
||||||
result := &Result{Success: true, Errors: []string{}}
|
|
||||||
service.getIp(result)
|
|
||||||
if len(result.Errors) == 0 {
|
|
||||||
service.getStatus(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 {
|
type ConditionResult struct {
|
||||||
Condition *Condition `json:"condition"`
|
Condition string `json:"condition"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Explanation string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Condition string
|
|
||||||
|
|
||||||
func (c *Condition) evaluate(result *Result) bool {
|
|
||||||
condition := string(*c)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeAndResolve(list []string, result *Result) []string {
|
|
||||||
var sanitizedList []string
|
|
||||||
for _, element := range list {
|
|
||||||
element = strings.TrimSpace(element)
|
|
||||||
switch strings.ToUpper(element) {
|
|
||||||
case "[STATUS]":
|
|
||||||
element = strconv.Itoa(result.HttpStatus)
|
|
||||||
case "[IP]":
|
|
||||||
element = result.Ip
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
sanitizedList = append(sanitizedList, element)
|
|
||||||
}
|
|
||||||
return sanitizedList
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEvaluateWithIp(t *testing.T) {
|
|
||||||
condition := Condition("[IP] == 127.0.0.1")
|
|
||||||
result := &Result{Ip: "127.0.0.1"}
|
|
||||||
condition.evaluate(result)
|
|
||||||
if !result.ConditionResults[0].Success {
|
|
||||||
t.Errorf("Condition '%s' should have been a success", condition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateWithStatus(t *testing.T) {
|
|
||||||
condition := Condition("[STATUS] == 201")
|
|
||||||
result := &Result{HttpStatus: 201}
|
|
||||||
condition.evaluate(result)
|
|
||||||
if !result.ConditionResults[0].Success {
|
|
||||||
t.Errorf("Condition '%s' should have been a success", condition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateWithFailure(t *testing.T) {
|
|
||||||
condition := Condition("[STATUS] == 200")
|
|
||||||
result := &Result{HttpStatus: 500}
|
|
||||||
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",
|
|
||||||
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: "GitHub",
|
|
||||||
Url: "https://api.github.com/healthz",
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
61
core/util.go
Normal file
61
core/util.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
|
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 evaluate json path
|
||||||
|
if strings.HasPrefix(element, BodyPlaceHolder) {
|
||||||
|
resolvedElement, 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/TwinProduction/gatus
|
module github.com/TwinProduction/gatus
|
||||||
|
|
||||||
go 1.13
|
go 1.14
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/prometheus/client_golang v1.2.1
|
github.com/prometheus/client_golang v1.2.1
|
||||||
|
|||||||
67
jsonpath/jsonpath.go
Normal file
67
jsonpath/jsonpath.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package jsonpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Eval is a half-baked json path implementation that needs some love
|
||||||
|
func Eval(path string, b []byte) (string, error) {
|
||||||
|
var object interface{}
|
||||||
|
err := json.Unmarshal(b, &object)
|
||||||
|
if err != nil {
|
||||||
|
// Try to unmarshal it into an array instead
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return walk(path, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
func walk(path string, object interface{}) (string, error) {
|
||||||
|
keys := strings.Split(path, ".")
|
||||||
|
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 interface{}:
|
||||||
|
return fmt.Sprintf("%v", value), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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]
|
||||||
|
}
|
||||||
148
jsonpath/jsonpath_test.go
Normal file
148
jsonpath/jsonpath_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package jsonpath
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEval(t *testing.T) {
|
||||||
|
path := "simple"
|
||||||
|
data := `{"simple": "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 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
22
main.go
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/TwinProduction/gatus/config"
|
"github.com/TwinProduction/gatus/config"
|
||||||
"github.com/TwinProduction/gatus/core"
|
|
||||||
"github.com/TwinProduction/gatus/watchdog"
|
"github.com/TwinProduction/gatus/watchdog"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"log"
|
"log"
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := loadConfiguration()
|
cfg := loadConfiguration()
|
||||||
go watchdog.Monitor(cfg)
|
|
||||||
http.HandleFunc("/api/v1/results", serviceResultsHandler)
|
http.HandleFunc("/api/v1/results", serviceResultsHandler)
|
||||||
http.HandleFunc("/health", healthHandler)
|
http.HandleFunc("/health", healthHandler)
|
||||||
http.Handle("/", http.FileServer(http.Dir("./static")))
|
http.Handle("/", http.FileServer(http.Dir("./static")))
|
||||||
@@ -21,6 +19,7 @@ func main() {
|
|||||||
http.Handle("/metrics", promhttp.Handler())
|
http.Handle("/metrics", promhttp.Handler())
|
||||||
}
|
}
|
||||||
log.Println("[main][main] Listening on port 8080")
|
log.Println("[main][main] Listening on port 8080")
|
||||||
|
go watchdog.Monitor(cfg)
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,21 +39,20 @@ func loadConfiguration() *config.Config {
|
|||||||
|
|
||||||
func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) {
|
func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||||
serviceResults := watchdog.GetServiceResults()
|
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.Header().Add("Content-type", "application/json")
|
||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
_, _ = writer.Write(structToJsonBytes(serviceResults))
|
_, _ = writer.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||||
writer.Header().Add("Content-type", "application/json")
|
writer.Header().Add("Content-type", "application/json")
|
||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
_, _ = writer.Write(structToJsonBytes(&core.HealthStatus{Status: "UP"}))
|
_, _ = writer.Write([]byte("{\"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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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">
|
<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>
|
<style>
|
||||||
|
html, body {
|
||||||
|
background-color: #f7f9fb;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
#results div.container:first-child {
|
#results div.container:first-child {
|
||||||
border-top-left-radius: 3px;
|
border-top-left-radius: 3px;
|
||||||
border-top-right-radius: 3px;
|
border-top-right-radius: 3px;
|
||||||
@@ -22,59 +32,149 @@
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
background-color: #28a745;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container my-3 bg-light rounded p-4 border shadow">
|
<div class="container my-3 rounded p-4 border shadow">
|
||||||
<div class="text-center mb-3">
|
<div class="mb-3">
|
||||||
<div class="display-4">Status</div>
|
<div class="display-4">Health Status</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
|
<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 src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
//const OK = "<div class='status-ok' title='__RESPONSE_TIME____CONDITIONS____ERRORS__'></div>"
|
let serviceStatuses = {};
|
||||||
const OK = "<span class='badge badge-success ml-1' style='width: 5%' title='__RESPONSE_TIME____CONDITIONS____ERRORS__'>✓</span>";
|
let timerHandler = 0;
|
||||||
const NOK = "<span class='badge badge-danger ml-1' style='width: 5%' title='__RESPONSE_TIME____CONDITIONS____ERRORS__'>X</span>";
|
let userClickedStatus = false;
|
||||||
|
|
||||||
function generateServiceResultBox(serviceResult) {
|
function showTooltip(serviceName, index, element) {
|
||||||
let output = (serviceResult.success ? OK : NOK);
|
userClickedStatus = false;
|
||||||
output = output.replace("__RESPONSE_TIME__", "Response time:\n" + parseInt(serviceResult.duration/1000000) + "ms");
|
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 = "";
|
let conditions = "";
|
||||||
for (let conditionResultIndex in serviceResult['condition-results']) {
|
for (let i in serviceResult['condition-results']) {
|
||||||
let conditionResult = serviceResult['condition-results'][conditionResultIndex];
|
let conditionResult = serviceResult['condition-results'][i];
|
||||||
conditions += "\n- " + (conditionResult.success ? "✓" : "X") + " ~ " + conditionResult.condition;
|
conditions += (conditionResult.success ? "✓" : "X") + " ~ " + htmlEntities(conditionResult.condition) + "<br />";
|
||||||
}
|
}
|
||||||
output = output.replace("__CONDITIONS__", "\n\nConditions:" + conditions);
|
$("#tooltip-conditions").html(conditions);
|
||||||
if (serviceResult['errors'].length > 0) {
|
// Populate the error section only if there are errors
|
||||||
|
if (serviceResult.errors && serviceResult.errors.length > 0) {
|
||||||
let errors = "";
|
let errors = "";
|
||||||
for (let errorIndex in serviceResult['errors']) {
|
for (let i in serviceResult.errors) {
|
||||||
errors += "\n- " + serviceResult['errors'][errorIndex];
|
errors += "- " + htmlEntities(serviceResult.errors[i]) + "<br />";
|
||||||
}
|
}
|
||||||
output = output.replace("__ERRORS__", "\n\nErrors: " + errors);
|
$("#tooltip-errors").html(errors);
|
||||||
|
$("#tooltip-errors-container").show();
|
||||||
} else {
|
} 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();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;'>✓</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() {
|
function refreshResults() {
|
||||||
$.getJSON("/api/v1/results", function (data) {
|
$.getJSON("/api/v1/results", function (data) {
|
||||||
|
serviceStatuses = data;
|
||||||
let output = "";
|
let output = "";
|
||||||
for (let serviceName in data) {
|
for (let serviceName in data) {
|
||||||
let serviceStatusOverTime = "";
|
let serviceStatusOverTime = "";
|
||||||
let hostname = data[serviceName][data[serviceName].length-1].hostname
|
let hostname = data[serviceName][data[serviceName].length-1].hostname
|
||||||
let minResponseTime = null;
|
let minResponseTime = null;
|
||||||
let maxResponseTime = null;
|
let maxResponseTime = null;
|
||||||
|
let newestTimestamp = null;
|
||||||
|
let oldestTimestamp = null;
|
||||||
for (let key in data[serviceName]) {
|
for (let key in data[serviceName]) {
|
||||||
let serviceResult = data[serviceName][key];
|
let serviceResult = data[serviceName][key];
|
||||||
console.log(serviceResult);
|
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
|
||||||
serviceStatusOverTime = generateServiceResultBox(serviceResult) + serviceStatusOverTime;
|
|
||||||
const responseTime = parseInt(serviceResult.duration/1000000);
|
const responseTime = parseInt(serviceResult.duration/1000000);
|
||||||
if (minResponseTime == null || minResponseTime > responseTime) {
|
if (minResponseTime == null || minResponseTime > responseTime) {
|
||||||
minResponseTime = responseTime;
|
minResponseTime = responseTime;
|
||||||
@@ -82,9 +182,16 @@
|
|||||||
if (maxResponseTime == null || maxResponseTime < responseTime) {
|
if (maxResponseTime == null || maxResponseTime < responseTime) {
|
||||||
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 += ""
|
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='row mb-2'>"
|
||||||
+ " <div class='col-8'>"
|
+ " <div class='col-8'>"
|
||||||
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
||||||
@@ -94,20 +201,61 @@
|
|||||||
+ " </div>"
|
+ " </div>"
|
||||||
+ " </div>"
|
+ " </div>"
|
||||||
+ " <div class='row'>"
|
+ " <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
|
+ " " + serviceStatusOverTime
|
||||||
+ " </div>"
|
+ " </div>"
|
||||||
+ " </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>";
|
+ "</div>";
|
||||||
}
|
}
|
||||||
$("#results").html(output);
|
$("#results").html(output);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
refreshResults();
|
refreshResults();
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
refreshResults();
|
refreshResults();
|
||||||
}, 10000);
|
}, 30000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
2
vendor/modules.txt
vendored
2
vendor/modules.txt
vendored
@@ -7,6 +7,7 @@ github.com/golang/protobuf/proto
|
|||||||
# github.com/matttproud/golang_protobuf_extensions v1.0.1
|
# github.com/matttproud/golang_protobuf_extensions v1.0.1
|
||||||
github.com/matttproud/golang_protobuf_extensions/pbutil
|
github.com/matttproud/golang_protobuf_extensions/pbutil
|
||||||
# github.com/prometheus/client_golang v1.2.1
|
# github.com/prometheus/client_golang v1.2.1
|
||||||
|
## explicit
|
||||||
github.com/prometheus/client_golang/prometheus
|
github.com/prometheus/client_golang/prometheus
|
||||||
github.com/prometheus/client_golang/prometheus/internal
|
github.com/prometheus/client_golang/prometheus/internal
|
||||||
github.com/prometheus/client_golang/prometheus/promauto
|
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 v0.0.0-20191010194322-b09406accb47
|
||||||
golang.org/x/sys/windows
|
golang.org/x/sys/windows
|
||||||
# gopkg.in/yaml.v2 v2.2.2
|
# gopkg.in/yaml.v2 v2.2.2
|
||||||
|
## explicit
|
||||||
gopkg.in/yaml.v2
|
gopkg.in/yaml.v2
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func Monitor(cfg *config.Config) {
|
|||||||
for _, service := range cfg.Services {
|
for _, service := range cfg.Services {
|
||||||
go monitor(service)
|
go monitor(service)
|
||||||
// To prevent multiple requests from running at the same time
|
// To prevent multiple requests from running at the same time
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(1111 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user