Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23ba7b85d | ||
|
|
0541adec5e | ||
|
|
eee5bc8f9d | ||
|
|
4d186c6e71 | ||
|
|
4be2273662 | ||
|
|
ef9ba10e45 | ||
|
|
b35747bf31 | ||
|
|
8cd82605dd | ||
|
|
7f0b862822 | ||
|
|
7f9fe9acdf | ||
|
|
46d476128d | ||
|
|
e309ffb1b6 | ||
|
|
48a1e435e4 | ||
|
|
6c91bbcc4a | ||
|
|
d1fe8d93ee | ||
|
|
afdbb1ea77 | ||
|
|
d58a256628 | ||
|
|
971967ae78 | ||
|
|
43504913b4 | ||
|
|
f034bd15de | ||
|
|
35c232d925 | ||
|
|
9c8bfcd19f | ||
|
|
6a08c816e5 | ||
|
|
da4fb10bfc | ||
|
|
35b9758b23 | ||
|
|
94eb3868e6 |
BIN
.github/assets/service-groups.png
vendored
Normal file
BIN
.github/assets/service-groups.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
32
.github/workflows/publish.yml
vendored
Normal file
32
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: publish
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
build:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Get the release
|
||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||
73
README.md
73
README.md
@@ -43,6 +43,8 @@ core applications: https://status.twinnation.org/
|
||||
- [Monitoring using DNS queries](#monitoring-using-dns-queries)
|
||||
- [Basic authentication](#basic-authentication)
|
||||
- [disable-monitoring-lock](#disable-monitoring-lock)
|
||||
- [Service groups](#service-groups)
|
||||
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||
|
||||
|
||||
## Features
|
||||
@@ -97,6 +99,7 @@ Note that you can also add environment variables in the configuration file (i.e.
|
||||
| `metrics` | Whether to expose metrics at /metrics | `false` |
|
||||
| `services` | List of services to monitor | Required `[]` |
|
||||
| `services[].name` | Name of the service. Can be anything. | Required `""` |
|
||||
| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` |
|
||||
| `services[].url` | URL to send the request to | Required `""` |
|
||||
| `services[].method` | Request method | `GET` |
|
||||
| `services[].insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
|
||||
@@ -142,9 +145,10 @@ Note that you can also add environment variables in the configuration file (i.e.
|
||||
| `security.basic.username` | Username for Basic authentication | Required `""` |
|
||||
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication | Required `""` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock) | `false` |
|
||||
| `web` | Web configuration | `{}` |
|
||||
| `web.address` | Address to listen on | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on | `8080` |
|
||||
| `web.context-root` | Context root which should be used by the web frontent | `/` |
|
||||
| `web.context-root` | Context root at which Gatus will be exposed (frontend and backend) | `/` |
|
||||
|
||||
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
|
||||
|
||||
@@ -460,6 +464,7 @@ If you're on Windows, replace `"$(pwd)"` by the absolute path to your current di
|
||||
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
|
||||
```
|
||||
|
||||
|
||||
## Running the tests
|
||||
|
||||
```
|
||||
@@ -495,7 +500,7 @@ services:
|
||||
}
|
||||
}
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
Content-Type: application/json # XXX: as of v1.9.2, this header is automatically added when graphql is set to true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].data.user[0].gender == female"
|
||||
@@ -572,7 +577,7 @@ established.
|
||||
Defining a `dns` configuration in a service will automatically mark that service as a service of type DNS:
|
||||
```yaml
|
||||
- name: example dns query
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
interval: 30s
|
||||
dns:
|
||||
query-name: "example.com"
|
||||
@@ -614,3 +619,65 @@ There are three main reasons why you might want to disable the monitoring lock:
|
||||
technically, if you create 100 services with a 1 seconds interval, Gatus will send 100 requests per second)
|
||||
- You have a _lot_ of services to monitor
|
||||
- You want to test multiple services at very short interval (< 5s)
|
||||
|
||||
|
||||
### Service groups
|
||||
|
||||
Service groups are used for grouping multiple services together on the dashboard.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
- name: frontend
|
||||
group: core
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: backend
|
||||
group: core
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: monitoring
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: nas
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: random service that isn't part of a group
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
The configuration above will result in a dashboard that looks like this:
|
||||
|
||||

|
||||
|
||||
|
||||
### Exposing Gatus on a custom port
|
||||
|
||||
By default, Gatus is exposed on port `8080`, but you may specify a different port by setting the `web.port` parameter:
|
||||
```yaml
|
||||
web:
|
||||
port: 8081
|
||||
```
|
||||
|
||||
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
|
||||
variable instead, you can use that environment variable directly in the configuration file:
|
||||
```yaml
|
||||
web:
|
||||
port: ${PORT}
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
return provider
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildRequest(serviceName, alertDescription string, resolved bool) *http.Request {
|
||||
func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription string, resolved bool) *http.Request {
|
||||
body := provider.Body
|
||||
providerURL := provider.URL
|
||||
method := provider.Method
|
||||
@@ -74,7 +74,7 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
|
||||
|
||||
// Send a request to the alert provider and return the body
|
||||
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
|
||||
request := provider.buildRequest(serviceName, alertDescription, resolved)
|
||||
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
|
||||
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -17,7 +18,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
|
||||
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,RESOLVED"
|
||||
@@ -27,7 +28,7 @@ func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
}
|
||||
request := customAlertProvider.buildRequest("service-name", "alert-description", true)
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
@@ -37,7 +38,7 @@ func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
|
||||
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,TRIGGERED"
|
||||
@@ -47,7 +48,7 @@ func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: map[string]string{"Authorization": "Basic hunter2"},
|
||||
}
|
||||
request := customAlertProvider.buildRequest("service-name", "alert-description", false)
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", false)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package mattermost
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
30
config.yaml
30
config.yaml
@@ -1,15 +1,37 @@
|
||||
metrics: true
|
||||
services:
|
||||
- name: twinnation
|
||||
- name: frontend
|
||||
group: core
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 1000"
|
||||
|
||||
- name: backend
|
||||
group: core
|
||||
url: "http://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: monitoring
|
||||
group: internal
|
||||
url: "http://example.com/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: nas
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: cat-fact
|
||||
url: "https://cat-fact.herokuapp.com/facts/random"
|
||||
interval: 1m
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].deleted == false"
|
||||
|
||||
@@ -59,5 +59,5 @@ func (web *webConfig) SocketAddress() string {
|
||||
|
||||
// PrependWithContextRoot appends the given path to the ContextRoot
|
||||
func (web *webConfig) PrependWithContextRoot(path string) string {
|
||||
return web.ContextRoot + strings.Trim(path, "/") + "/"
|
||||
return web.ContextRoot + strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
|
||||
func TestWebConfig_PrependWithContextRoot(t *testing.T) {
|
||||
web := &webConfig{ContextRoot: "/status/"}
|
||||
if result := web.PrependWithContextRoot("/api/v1/results"); result != "/status/api/v1/results/" {
|
||||
t.Errorf("expected %s, got %s", "/status/api/v1/results/", result)
|
||||
if result := web.PrependWithContextRoot("/api/v1/results"); result != "/status/api/v1/results" {
|
||||
t.Errorf("expected %s, got %s", "/status/api/v1/results", result)
|
||||
}
|
||||
if result := web.PrependWithContextRoot("/health"); result != "/status/health/" {
|
||||
t.Errorf("expected %s, got %s", "/status/health/", result)
|
||||
if result := web.PrependWithContextRoot("/health"); result != "/status/health" {
|
||||
t.Errorf("expected %s, got %s", "/status/health", result)
|
||||
}
|
||||
if result := web.PrependWithContextRoot("/health/"); result != "/status/health/" {
|
||||
t.Errorf("expected %s, got %s", "/status/health/", result)
|
||||
if result := web.PrependWithContextRoot("/health/"); result != "/status/health" {
|
||||
t.Errorf("expected %s, got %s", "/status/health", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,10 @@ var validContextRootTests = []struct {
|
||||
{"Slash at the beginning", "/status", "/status/"},
|
||||
{"Slashes at start and end", "/status/", "/status/"},
|
||||
{"Multiple slashes at start", "//status", "/status/"},
|
||||
{"Mutliple slashes at start and end", "///status////", "/status/"},
|
||||
{"Multiple slashes at start and end", "///status////", "/status/"},
|
||||
{"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"},
|
||||
{"nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
|
||||
{"nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
|
||||
{"Nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
|
||||
{"Nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
|
||||
}
|
||||
|
||||
func TestWebConfig_ValidContextRoots(t *testing.T) {
|
||||
@@ -56,6 +56,16 @@ func TestWebConfig_ValidContextRoots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
|
||||
web := &webConfig{
|
||||
ContextRoot: path,
|
||||
}
|
||||
web.validateAndSetDefaults()
|
||||
if web.ContextRoot != expected {
|
||||
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// invalidContextRootTests contains all tests for context root which are
|
||||
// expected to fail and stop program execution
|
||||
var invalidContextRootTests = []struct {
|
||||
@@ -63,12 +73,12 @@ var invalidContextRootTests = []struct {
|
||||
path string
|
||||
}{
|
||||
{"Only a fragment identifier", "#"},
|
||||
{"Invalid character in path", "/invalid" + string([]byte{0x7F}) + "/"},
|
||||
{"Invalid character in path", "/invalid" + string([]byte{0x7F})},
|
||||
{"Starts with protocol", "http://status/gatus"},
|
||||
{"Path with fragment", "/status/gatus#here"},
|
||||
{"starts with '://'", "://status"},
|
||||
{"contains query parameter", "/status/h?ello=world"},
|
||||
{"contains '?'", "/status?/"},
|
||||
{"Starts with '://'", "://status"},
|
||||
{"Contains query parameter", "/status/h?ello=world"},
|
||||
{"Contains '?'", "/status?"},
|
||||
}
|
||||
|
||||
func TestWebConfig_InvalidContextRoots(t *testing.T) {
|
||||
@@ -87,15 +97,3 @@ func expectInvalidResultForContextRoot(t *testing.T, path string) {
|
||||
|
||||
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
|
||||
}
|
||||
|
||||
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
|
||||
web := &webConfig{
|
||||
ContextRoot: path,
|
||||
}
|
||||
|
||||
web.validateAndSetDefaults()
|
||||
|
||||
if web.ContextRoot != expected {
|
||||
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
10
core/condition-result.go
Normal file
10
core/condition-result.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package core
|
||||
|
||||
// ConditionResult result of a Condition
|
||||
type ConditionResult struct {
|
||||
// Condition that was evaluated
|
||||
Condition string `json:"condition"`
|
||||
|
||||
// Success whether the condition was met (successful) or not (failed)
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
var (
|
||||
// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name
|
||||
ErrDNSWithNoQueryName = errors.New("you must specify a query name for DNS")
|
||||
|
||||
// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type
|
||||
ErrDNSWithInvalidQueryType = errors.New("invalid query type")
|
||||
)
|
||||
@@ -19,9 +20,11 @@ const (
|
||||
dnsPort = 53
|
||||
)
|
||||
|
||||
// DNS is the configuration for a Service of type DNS
|
||||
type DNS struct {
|
||||
// QueryType is the type for the DNS records like A, AAAA, CNAME...
|
||||
QueryType string `yaml:"query-type"`
|
||||
|
||||
// QueryName is the query for DNS
|
||||
QueryName string `yaml:"query-name"`
|
||||
}
|
||||
|
||||
11
core/health-status.go
Normal file
11
core/health-status.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package core
|
||||
|
||||
// HealthStatus is the status of Gatus
|
||||
type HealthStatus struct {
|
||||
// Status is the state of Gatus (UP/DOWN)
|
||||
Status string `json:"status"`
|
||||
|
||||
// Message is an accompanying description of why the status is as reported.
|
||||
// If the Status is UP, no message will be provided
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
@@ -4,23 +4,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthStatus is the status of Gatus
|
||||
type HealthStatus struct {
|
||||
// Status is the state of Gatus (UP/DOWN)
|
||||
Status string `json:"status"`
|
||||
|
||||
// Message is an accompanying description of why the status is as reported.
|
||||
// If the Status is UP, no message will be provided
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Result of the evaluation of a Service
|
||||
type Result struct {
|
||||
// HTTPStatus is the HTTP response status code
|
||||
HTTPStatus int `json:"status"`
|
||||
|
||||
// DNSRCode is the response code of DNS query in human readable version
|
||||
DNSRCode string `json:"dns-rcode"`
|
||||
// DNSRCode is the response code of a DNS query in a human readable format
|
||||
DNSRCode string `json:"-"`
|
||||
|
||||
// Body is the response body
|
||||
Body []byte `json:"-"`
|
||||
@@ -50,14 +40,5 @@ type Result struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// CertificateExpiration is the duration before the certificate expires
|
||||
CertificateExpiration time.Duration `json:"certificate-expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ConditionResult result of a Condition
|
||||
type ConditionResult struct {
|
||||
// Condition that was evaluated
|
||||
Condition string `json:"condition"`
|
||||
|
||||
// Success whether the condition was met (successful) or not (failed)
|
||||
Success bool `json:"success"`
|
||||
CertificateExpiration time.Duration `json:"-"`
|
||||
}
|
||||
31
core/service-status.go
Normal file
31
core/service-status.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package core
|
||||
|
||||
// ServiceStatus contains the evaluation Results of a Service
|
||||
type ServiceStatus struct {
|
||||
// Name of the service
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group the service is a part of. Used for grouping multiple services together on the front end.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Results is the list of service evaluation results
|
||||
Results []*Result `json:"results"`
|
||||
}
|
||||
|
||||
// NewServiceStatus creates a new ServiceStatus
|
||||
func NewServiceStatus(service *Service) *ServiceStatus {
|
||||
return &ServiceStatus{
|
||||
Name: service.Name,
|
||||
Group: service.Group,
|
||||
Results: make([]*Result, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddResult adds a Result to ServiceStatus.Results and makes sure that there are
|
||||
// no more than 20 results in the Results slice
|
||||
func (ss *ServiceStatus) AddResult(result *Result) {
|
||||
ss.Results = append(ss.Results, result)
|
||||
if len(ss.Results) > 20 {
|
||||
ss.Results = ss.Results[1:]
|
||||
}
|
||||
}
|
||||
25
core/service-status_test.go
Normal file
25
core/service-status_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewServiceStatus(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
if serviceStatus.Name != service.Name {
|
||||
t.Errorf("expected %s, got %s", service.Name, serviceStatus.Name)
|
||||
}
|
||||
if serviceStatus.Group != service.Group {
|
||||
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatus_AddResult(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
for i := 0; i < 50; i++ {
|
||||
serviceStatus.AddResult(&Result{})
|
||||
}
|
||||
if len(serviceStatus.Results) != 20 {
|
||||
t.Errorf("expected serviceStatus.Results to not exceed a length of 20")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,14 @@ import (
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
)
|
||||
|
||||
const (
|
||||
// HostHeader is the name of the header used to specify the host
|
||||
HostHeader = "Host"
|
||||
|
||||
// ContentTypeHeader is the name of the header used to specify the content type
|
||||
ContentTypeHeader = "Content-Type"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceWithNoCondition is the error with which gatus will panic if a service is configured with no conditions
|
||||
ErrServiceWithNoCondition = errors.New("you must specify at least one condition per service")
|
||||
@@ -30,6 +38,9 @@ type Service struct {
|
||||
// Name of the service. Can be anything.
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Group the service is a part of. Used for grouping multiple services together on the front end.
|
||||
Group string `yaml:"group,omitempty"`
|
||||
|
||||
// URL to send the request to
|
||||
URL string `yaml:"url"`
|
||||
|
||||
@@ -79,6 +90,11 @@ func (service *Service) ValidateAndSetDefaults() {
|
||||
if len(service.Headers) == 0 {
|
||||
service.Headers = make(map[string]string)
|
||||
}
|
||||
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
|
||||
// and service.GraphQL is set to true
|
||||
if _, contentTypeHeaderExists := service.Headers[ContentTypeHeader]; !contentTypeHeaderExists && service.GraphQL {
|
||||
service.Headers[ContentTypeHeader] = "application/json"
|
||||
}
|
||||
for _, alert := range service.Alerts {
|
||||
if alert.FailureThreshold <= 0 {
|
||||
alert.FailureThreshold = 3
|
||||
@@ -213,6 +229,9 @@ func (service *Service) buildHTTPRequest() *http.Request {
|
||||
request, _ := http.NewRequest(service.Method, service.URL, bodyBuffer)
|
||||
for k, v := range service.Headers {
|
||||
request.Header.Set(k, v)
|
||||
if k == HostHeader {
|
||||
request.Host = v
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -113,6 +115,75 @@ func TestService_GetAlertsTriggered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequest(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "GET" {
|
||||
t.Error("request.Method should've been GET, but was", request.Method)
|
||||
}
|
||||
if request.Host != "twinnation.org" {
|
||||
t.Error("request.Host should've been twinnation.org, but was", request.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
URL: "https://twinnation.org/health",
|
||||
Method: "POST",
|
||||
Conditions: []*Condition{&condition},
|
||||
Headers: map[string]string{
|
||||
"Host": "example.com",
|
||||
},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "POST" {
|
||||
t.Error("request.Method should've been POST, but was", request.Method)
|
||||
}
|
||||
if request.Host != "example.com" {
|
||||
t.Error("request.Host should've been example.com, but was", request.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
URL: "https://twinnation.org/graphql",
|
||||
Method: "POST",
|
||||
Conditions: []*Condition{&condition},
|
||||
GraphQL: true,
|
||||
Body: `{
|
||||
user(gender: "female") {
|
||||
id
|
||||
name
|
||||
gender
|
||||
avatar
|
||||
}
|
||||
}`,
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "POST" {
|
||||
t.Error("request.Method should've been POST, but was", request.Method)
|
||||
}
|
||||
if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" {
|
||||
t.Error("request.Header.Content-Type should've been application/json, but was", contentType)
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
if !strings.HasPrefix(string(body), "{\"query\":") {
|
||||
t.Error("request.Body should've started with '{\"query\":', but it didn't:", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
|
||||
3
gzip.go
3
gzip.go
@@ -20,11 +20,14 @@ type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
// WriteHeader sends an HTTP response header with the provided status code.
|
||||
// It also deletes the Content-Length header, since the GZIP compression may modify the size of the payload
|
||||
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
w.Header().Del("Content-Length")
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write writes len(b) bytes from b to the underlying data stream.
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
|
||||
30
main.go
30
main.go
@@ -18,19 +18,19 @@ import (
|
||||
const cacheTTL = 10 * time.Second
|
||||
|
||||
var (
|
||||
cachedServiceResults []byte
|
||||
cachedServiceResultsGzipped []byte
|
||||
cachedServiceResultsTimestamp time.Time
|
||||
cachedServiceStatuses []byte
|
||||
cachedServiceStatusesGzipped []byte
|
||||
cachedServiceStatusesTimestamp time.Time
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := loadConfiguration()
|
||||
resultsHandler := serviceResultsHandler
|
||||
statusesHandler := serviceStatusesHandler
|
||||
if cfg.Security != nil && cfg.Security.IsValid() {
|
||||
resultsHandler = security.Handler(serviceResultsHandler, cfg.Security)
|
||||
statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security)
|
||||
}
|
||||
http.HandleFunc("/favicon.ico", favIconHandler) // favicon needs to be always served from the root
|
||||
http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/results"), resultsHandler)
|
||||
http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler)
|
||||
http.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler)
|
||||
http.Handle(cfg.Web.ContextRoot, GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static")))))
|
||||
|
||||
@@ -56,29 +56,29 @@ func loadConfiguration() *config.Config {
|
||||
return config.Get()
|
||||
}
|
||||
|
||||
func serviceResultsHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
if isExpired := cachedServiceResultsTimestamp.IsZero() || time.Now().Sub(cachedServiceResultsTimestamp) > cacheTTL; isExpired {
|
||||
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
if isExpired := cachedServiceStatusesTimestamp.IsZero() || time.Now().Sub(cachedServiceStatusesTimestamp) > cacheTTL; isExpired {
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
data, err := watchdog.GetJSONEncodedServiceResults()
|
||||
data, err := watchdog.GetJSONEncodedServiceStatuses()
|
||||
if err != nil {
|
||||
log.Printf("[main][serviceResultsHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
||||
return
|
||||
}
|
||||
gzipWriter.Write(data)
|
||||
gzipWriter.Close()
|
||||
cachedServiceResults = data
|
||||
cachedServiceResultsGzipped = buffer.Bytes()
|
||||
cachedServiceResultsTimestamp = time.Now()
|
||||
cachedServiceStatuses = data
|
||||
cachedServiceStatusesGzipped = buffer.Bytes()
|
||||
cachedServiceStatusesTimestamp = time.Now()
|
||||
}
|
||||
var data []byte
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
data = cachedServiceResultsGzipped
|
||||
data = cachedServiceStatusesGzipped
|
||||
} else {
|
||||
data = cachedServiceResults
|
||||
data = cachedServiceStatuses
|
||||
}
|
||||
writer.Header().Add("Content-type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
.service-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.service-group h5:hover {
|
||||
color: #1b1e21 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,7 +169,7 @@
|
||||
function showTooltip(serviceName, index, element) {
|
||||
userClickedStatus = false;
|
||||
clearTimeout(timerHandler);
|
||||
let serviceResult = serviceStatuses[serviceName][index];
|
||||
let serviceResult = serviceStatuses[serviceName].results[index];
|
||||
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
|
||||
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
|
||||
// Populate the condition section
|
||||
@@ -212,15 +219,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function createStatusBadge(serviceName, index, success) {
|
||||
function createStatusBadge(serviceStatusIndex, 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-success' style='width: 5%' onmouseenter='showTooltip(\""+serviceStatusIndex+"\", "+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>";
|
||||
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\""+serviceStatusIndex+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
|
||||
}
|
||||
|
||||
function refreshResults() {
|
||||
$.getJSON("./api/v1/results", function (data) {
|
||||
function refreshStatuses() {
|
||||
$.getJSON("./api/v1/statuses", function (data) {
|
||||
// Update the table only if there's a change
|
||||
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
||||
serviceStatuses = data;
|
||||
@@ -230,17 +237,18 @@
|
||||
}
|
||||
|
||||
function buildTable() {
|
||||
let output = "";
|
||||
for (let serviceName in serviceStatuses) {
|
||||
let outputByGroup = {};
|
||||
for (let serviceStatusIndex in serviceStatuses) {
|
||||
let serviceStatusOverTime = "";
|
||||
let hostname = serviceStatuses[serviceName][serviceStatuses[serviceName].length-1].hostname
|
||||
let serviceStatus = serviceStatuses[serviceStatusIndex];
|
||||
let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname;
|
||||
let minResponseTime = null;
|
||||
let maxResponseTime = null;
|
||||
let newestTimestamp = null;
|
||||
let oldestTimestamp = null;
|
||||
for (let key in serviceStatuses[serviceName]) {
|
||||
let serviceResult = serviceStatuses[serviceName][key];
|
||||
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
|
||||
for (let resultIndex in serviceStatus.results) {
|
||||
let serviceResult = serviceStatus.results[resultIndex];
|
||||
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
|
||||
const responseTime = parseInt(serviceResult.duration/1000000);
|
||||
if (minResponseTime == null || minResponseTime > responseTime) {
|
||||
minResponseTime = responseTime;
|
||||
@@ -256,11 +264,11 @@
|
||||
oldestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
output += ""
|
||||
+ "<div class='container py-3 border-left border-right border-top border-black'>"
|
||||
let output = ""
|
||||
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
|
||||
+ " <div class='row mb-2'>"
|
||||
+ " <div class='col-md-10'>"
|
||||
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
||||
+ " <span class='font-weight-bold'>" + serviceStatus.name + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
||||
+ " </div>"
|
||||
+ " <div class='col-md-2 text-right'>"
|
||||
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
|
||||
@@ -280,10 +288,59 @@
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
// create an empty entry if this group is new
|
||||
if (!outputByGroup[serviceStatus.group]) {
|
||||
outputByGroup[serviceStatus.group] = "";
|
||||
}
|
||||
outputByGroup[serviceStatus.group] += output;
|
||||
}
|
||||
let output = "";
|
||||
for (let group in outputByGroup) {
|
||||
// Services that don't have a group should be skipped and left for last
|
||||
if (group === 'undefined') {
|
||||
continue
|
||||
}
|
||||
let key = group.replace(/[^a-zA-Z0-9]/g, '');
|
||||
let existingGroupContentSelector = $("#service-group-" + key + "-content");
|
||||
let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none';
|
||||
let groupStatus = "<span class='text-success'>✓</span>";
|
||||
if (outputByGroup[group].includes("badge badge-danger")) {
|
||||
groupStatus = "<span class='text-warning'>~</span>";
|
||||
}
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " <div class='container pt-2 border-left border-right border-top border-black border-bottom service-group' id='service-group-" + key + "' data-group='" + key + "' onclick='toggleGroup(this)'>"
|
||||
+ " <h5 class='text-secondary text-monospace pb-0'>"
|
||||
+ " " + groupStatus + " " + group
|
||||
+ " <span class='float-right service-group-arrow' id='service-group-" + key + "-arrow'>" + (isCurrentlyHidden ? "▼" : "▲") + "</span>"
|
||||
+ " </h5>"
|
||||
+ " </div>"
|
||||
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
|
||||
+ " " + outputByGroup[group]
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
}
|
||||
// Add all services that don't have a group at the end
|
||||
if (outputByGroup['undefined']) {
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " " + outputByGroup['undefined']
|
||||
+ "</div>"
|
||||
}
|
||||
$("#results").html(output);
|
||||
}
|
||||
|
||||
function toggleGroup(element) {
|
||||
let selector = $("#service-group-" + element.dataset.group + "-content");
|
||||
selector.toggle("fast", function() {
|
||||
if (selector.length && selector[0].style.display === 'none') {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▼");
|
||||
} else {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▲");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prettifyTimestamp(timestamp) {
|
||||
let date = new Date(timestamp);
|
||||
let YYYY = date.getFullYear();
|
||||
@@ -318,15 +375,15 @@
|
||||
}
|
||||
|
||||
function setRefreshInterval(seconds) {
|
||||
refreshResults();
|
||||
refreshStatuses();
|
||||
refreshIntervalHandler = setInterval(function() {
|
||||
refreshResults();
|
||||
}, seconds * 1000)
|
||||
refreshStatuses();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
$("#refresh-rate").change(function() {
|
||||
clearInterval(refreshIntervalHandler);
|
||||
setRefreshInterval($(this).val())
|
||||
setRefreshInterval($(this).val());
|
||||
});
|
||||
setRefreshInterval(30);
|
||||
$("#refresh-rate").val(30);
|
||||
|
||||
@@ -13,22 +13,22 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
serviceResults = make(map[string][]*core.Result)
|
||||
serviceStatuses = make(map[string]*core.ServiceStatus)
|
||||
|
||||
// serviceResultsMutex is used to prevent concurrent map access
|
||||
serviceResultsMutex sync.RWMutex
|
||||
// serviceStatusesMutex is used to prevent concurrent map access
|
||||
serviceStatusesMutex sync.RWMutex
|
||||
|
||||
// monitoringMutex is used to prevent multiple services from being evaluated at the same time.
|
||||
// Without this, conditions using response time may become inaccurate.
|
||||
monitoringMutex sync.Mutex
|
||||
)
|
||||
|
||||
// GetJSONEncodedServiceResults returns a list of the last 20 results for each services encoded using json.Marshal.
|
||||
// GetJSONEncodedServiceStatuses returns a list of core.ServiceStatus for each services encoded using json.Marshal.
|
||||
// The reason why the encoding is done here is because we use a mutex to prevent concurrent map access.
|
||||
func GetJSONEncodedServiceResults() ([]byte, error) {
|
||||
serviceResultsMutex.RLock()
|
||||
data, err := json.Marshal(serviceResults)
|
||||
serviceResultsMutex.RUnlock()
|
||||
func GetJSONEncodedServiceStatuses() ([]byte, error) {
|
||||
serviceStatusesMutex.RLock()
|
||||
data, err := json.Marshal(serviceStatuses)
|
||||
serviceStatusesMutex.RUnlock()
|
||||
return data, err
|
||||
}
|
||||
|
||||
@@ -55,12 +55,7 @@ func monitor(service *core.Service) {
|
||||
}
|
||||
result := service.EvaluateHealth()
|
||||
metric.PublishMetricsForService(service, result)
|
||||
serviceResultsMutex.Lock()
|
||||
serviceResults[service.Name] = append(serviceResults[service.Name], result)
|
||||
if len(serviceResults[service.Name]) > 20 {
|
||||
serviceResults[service.Name] = serviceResults[service.Name][1:]
|
||||
}
|
||||
serviceResultsMutex.Unlock()
|
||||
UpdateServiceStatuses(service, result)
|
||||
var extra string
|
||||
if !result.Success {
|
||||
extra = fmt.Sprintf("responseBody=%s", result.Body)
|
||||
@@ -83,3 +78,16 @@ func monitor(service *core.Service) {
|
||||
time.Sleep(service.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateServiceStatuses updates the slice of service statuses
|
||||
func UpdateServiceStatuses(service *core.Service, result *core.Result) {
|
||||
key := fmt.Sprintf("%s_%s", service.Group, service.Name)
|
||||
serviceStatusesMutex.Lock()
|
||||
serviceStatus, exists := serviceStatuses[key]
|
||||
if !exists {
|
||||
serviceStatus = core.NewServiceStatus(service)
|
||||
serviceStatuses[key] = serviceStatus
|
||||
}
|
||||
serviceStatus.AddResult(result)
|
||||
serviceStatusesMutex.Unlock()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user