Compare commits
45 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 | ||
|
|
68f32b0fcc | ||
|
|
66946e20f0 | ||
|
|
64f1c41e0c | ||
|
|
199f14667e | ||
|
|
c5e2b0f159 | ||
|
|
a645c14452 | ||
|
|
daf27fd6d0 | ||
|
|
978acf3cd2 | ||
|
|
a870d3e43f | ||
|
|
66bf1c23df | ||
|
|
4f17bc1b14 | ||
|
|
5850ed82e4 | ||
|
|
53e1012ca1 | ||
|
|
f36d99ef07 | ||
|
|
9af8a02f05 | ||
|
|
c827eb7948 | ||
|
|
c791986143 | ||
|
|
5f10a92c36 | ||
|
|
f9706a98ed |
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 }}
|
||||
106
README.md
106
README.md
@@ -27,6 +27,7 @@ core applications: https://status.twinnation.org/
|
||||
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
||||
- [Configuring Twilio alerts](#configuring-twilio-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Kubernetes (ALPHA)](#kubernetes-alpha)
|
||||
- [Auto Discovery](#auto-discovery)
|
||||
@@ -42,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
|
||||
@@ -50,7 +53,7 @@ The main features of Gatus are:
|
||||
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
|
||||
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
|
||||
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
|
||||
- **Metrics**
|
||||
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
|
||||
- **Service auto discovery in Kubernetes** (ALPHA)
|
||||
@@ -96,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` |
|
||||
@@ -107,7 +111,7 @@ Note that you can also add environment variables in the configuration file (i.e.
|
||||
| `services[].dns` | Configuration for a service of type DNS. See [Monitoring using DNS queries](#monitoring-using-dns-queries) | `""` |
|
||||
| `services[].dns.query-type` | Query type for DNS service | `""` |
|
||||
| `services[].dns.query-name` | Query name for DNS service | `""` |
|
||||
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `pagerduty`, `twilio`, `mattermost`, `custom` | Required `""` |
|
||||
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `custom` | Required `""` |
|
||||
| `services[].alerts[].enabled` | Whether to enable the alert | `false` |
|
||||
| `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert | `3` |
|
||||
| `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | `2` |
|
||||
@@ -126,6 +130,10 @@ Note that you can also add environment variables in the configuration file (i.e.
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
|
||||
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
|
||||
| `alerting.mattermost.insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
|
||||
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
@@ -137,8 +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 at which Gatus will be exposed (frontend and backend) | `/` |
|
||||
|
||||
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
|
||||
|
||||
@@ -305,6 +315,33 @@ Here's an example of what the notifications look like:
|
||||

|
||||
|
||||
|
||||
#### Configuring Messagebird alerts
|
||||
|
||||
Example of sending **SMS** text message alert using Messagebird:
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
messagebird:
|
||||
access-key: "..."
|
||||
originator: "31619191918"
|
||||
recipients: "31619191919,31619191920"
|
||||
services:
|
||||
- name: twinnation
|
||||
interval: 30s
|
||||
url: "https://twinnation.org/health"
|
||||
alerts:
|
||||
- type: messagebird
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring custom alerts
|
||||
|
||||
While they're called alerts, you can use this feature to call anything.
|
||||
@@ -427,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
|
||||
|
||||
```
|
||||
@@ -462,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"
|
||||
@@ -581,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}
|
||||
```
|
||||
|
||||
@@ -2,9 +2,10 @@ package alerting
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
)
|
||||
|
||||
@@ -16,6 +17,9 @@ type Config struct {
|
||||
// Mattermost is the configuration for the mattermost alerting provider
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
|
||||
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
|
||||
|
||||
// Pagerduty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +62,7 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
|
||||
}
|
||||
}
|
||||
if len(method) == 0 {
|
||||
method = "GET"
|
||||
method = http.MethodGet
|
||||
}
|
||||
bodyBuffer := bytes.NewBuffer([]byte(body))
|
||||
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package mattermost
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -40,7 +42,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: "POST",
|
||||
Method: http.MethodPost,
|
||||
Insecure: provider.Insecure,
|
||||
Body: fmt.Sprintf(`{
|
||||
"text": "",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
50
alerting/provider/messagebird/messagebird.go
Normal file
50
alerting/provider/messagebird/messagebird.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package messagebird
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
const (
|
||||
restAPIURL = "https://rest.messagebird.com/messages"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Messagebird
|
||||
type AlertProvider struct {
|
||||
AccessKey string `yaml:"access-key"`
|
||||
Originator string `yaml:"originator"`
|
||||
Recipients string `yaml:"recipients"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
}
|
||||
|
||||
return &custom.AlertProvider{
|
||||
URL: restAPIURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"originator": "%s",
|
||||
"recipients": "%s",
|
||||
"body": "%s"
|
||||
}`, provider.Originator, provider.Recipients, message),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
|
||||
},
|
||||
}
|
||||
}
|
||||
53
alerting/provider/messagebird/messagebird_test.go
Normal file
53
alerting/provider/messagebird/messagebird_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package messagebird
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package pagerduty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -19,7 +21,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
//
|
||||
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, eventAction, resolveKey string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
@@ -32,7 +34,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: "https://events.pagerduty.com/v2/enqueue",
|
||||
Method: "POST",
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"routing_key": "%s",
|
||||
"dedup_key": "%s",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,9 +2,10 @@ package provider
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -24,5 +25,6 @@ var (
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package slack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
@@ -38,7 +39,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: "POST",
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"text": "",
|
||||
"attachments": [
|
||||
|
||||
@@ -3,9 +3,11 @@ package twilio
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||
@@ -22,7 +24,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
@@ -31,7 +33,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
||||
Method: "POST",
|
||||
Method: http.MethodPost,
|
||||
Body: url.Values{
|
||||
"To": {provider.To},
|
||||
"From": {provider.From},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -28,6 +28,9 @@ const (
|
||||
|
||||
// DefaultPort is the default port the service will listen on
|
||||
DefaultPort = 8080
|
||||
|
||||
// DefaultContextRoot is the default context root of the web application
|
||||
DefaultContextRoot = "/"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -84,6 +87,7 @@ func Get() *Config {
|
||||
}
|
||||
|
||||
// Load loads a custom configuration file
|
||||
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
|
||||
func Load(configFile string) error {
|
||||
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
|
||||
cfg, err := readConfigurationFile(configFile)
|
||||
@@ -143,7 +147,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
|
||||
func validateWebConfig(config *Config) {
|
||||
if config.Web == nil {
|
||||
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort}
|
||||
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
|
||||
} else {
|
||||
config.Web.validateAndSetDefaults()
|
||||
}
|
||||
@@ -198,6 +202,7 @@ func validateAlertingConfig(config *Config) {
|
||||
alertTypes := []core.AlertType{
|
||||
core.SlackAlert,
|
||||
core.MattermostAlert,
|
||||
core.MessagebirdAlert,
|
||||
core.TwilioAlert,
|
||||
core.PagerDutyAlert,
|
||||
core.CustomAlert,
|
||||
@@ -234,6 +239,12 @@ func GetAlertingProviderByAlertType(config *Config, alertType core.AlertType) pr
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Mattermost
|
||||
case core.MessagebirdAlert:
|
||||
if config.Alerting.Messagebird == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Messagebird
|
||||
case core.TwilioAlert:
|
||||
if config.Alerting.Twilio == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
|
||||
@@ -109,6 +109,9 @@ services:
|
||||
if config.Web.Port != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
}
|
||||
if config.Web.ContextRoot != DefaultContextRoot {
|
||||
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
|
||||
@@ -215,6 +218,44 @@ services:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithPortAndHostAndContextRoot(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
web:
|
||||
port: 12345
|
||||
address: 127.0.0.1
|
||||
context-root: /deeply/nested/down=/their
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
if config.Metrics {
|
||||
t.Error("Metrics should've been false by default")
|
||||
}
|
||||
if config.Services[0].URL != "https://twinnation.org/health" {
|
||||
t.Errorf("URL should have been %s", "https://twinnation.org/health")
|
||||
}
|
||||
if config.Services[0].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if config.Web.Address != "127.0.0.1" {
|
||||
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
|
||||
}
|
||||
if config.Web.Port != 12345 {
|
||||
t.Errorf("Port should have been %d, because it is specified in config", 12345)
|
||||
}
|
||||
if config.Web.ContextRoot != "/deeply/nested/down=/their/" {
|
||||
t.Errorf("Port should have been %s, because it is specified in config", "/deeply/nested/down=/their/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||
@@ -260,6 +301,9 @@ services:
|
||||
if config.Web.Port != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
}
|
||||
if config.Web.ContextRoot != DefaultContextRoot {
|
||||
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {
|
||||
@@ -322,6 +366,10 @@ alerting:
|
||||
webhook-url: "http://example.com"
|
||||
pagerduty:
|
||||
integration-key: "00000000000000000000000000000000"
|
||||
messagebird:
|
||||
access-key: "1"
|
||||
originator: "31619191918"
|
||||
recipients: "31619191919"
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
@@ -333,6 +381,8 @@ services:
|
||||
failure-threshold: 7
|
||||
success-threshold: 5
|
||||
description: "Healthcheck failed 7 times in a row"
|
||||
- type: messagebird
|
||||
enabled: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -357,9 +407,21 @@ services:
|
||||
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
|
||||
t.Fatal("PagerDuty alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
|
||||
t.Fatal("Messagebird alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
|
||||
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
|
||||
}
|
||||
if config.Alerting.Messagebird.AccessKey != "1" {
|
||||
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
|
||||
}
|
||||
if config.Alerting.Messagebird.Originator != "31619191918" {
|
||||
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator)
|
||||
}
|
||||
if config.Alerting.Messagebird.Recipients != "31619191919" {
|
||||
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
|
||||
}
|
||||
if len(config.Services) != 1 {
|
||||
t.Error("There should've been 1 service")
|
||||
}
|
||||
@@ -372,8 +434,8 @@ services:
|
||||
if config.Services[0].Alerts == nil {
|
||||
t.Fatal("The service alerts shouldn't have been nil")
|
||||
}
|
||||
if len(config.Services[0].Alerts) != 2 {
|
||||
t.Fatal("There should've been 2 alert configured")
|
||||
if len(config.Services[0].Alerts) != 3 {
|
||||
t.Fatal("There should've been 3 alert configured")
|
||||
}
|
||||
if !config.Services[0].Alerts[0].Enabled {
|
||||
t.Error("The alert should've been enabled")
|
||||
@@ -399,6 +461,9 @@ services:
|
||||
if config.Services[0].Alerts[1].Description != "Healthcheck failed 7 times in a row" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Services[0].Alerts[0].Description)
|
||||
}
|
||||
if config.Services[0].Alerts[2].Type != core.MessagebirdAlert {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", core.MessagebirdAlert, config.Services[0].Alerts[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidPagerDutyAlertingConfig(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// webConfig is the structure which supports the configuration of the endpoint
|
||||
@@ -13,18 +15,40 @@ type webConfig struct {
|
||||
|
||||
// Port to listen on (default to 8080 specified by DefaultPort)
|
||||
Port int `yaml:"port"`
|
||||
|
||||
// ContextRoot set the root context for the web application
|
||||
ContextRoot string `yaml:"context-root"`
|
||||
}
|
||||
|
||||
// validateAndSetDefaults checks and sets missing values based on the defaults
|
||||
// in given in DefaultAddress and DefaultPort if necessary
|
||||
// validateAndSetDefaults checks and sets the default values for fields that are not set
|
||||
func (web *webConfig) validateAndSetDefaults() {
|
||||
// Validate the Address
|
||||
if len(web.Address) == 0 {
|
||||
web.Address = DefaultAddress
|
||||
}
|
||||
// Validate the Port
|
||||
if web.Port == 0 {
|
||||
web.Port = DefaultPort
|
||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
||||
panic(fmt.Sprintf("port has an invalid: value should be between %d and %d", 0, math.MaxUint16))
|
||||
panic(fmt.Sprintf("invalid port: value should be between %d and %d", 0, math.MaxUint16))
|
||||
}
|
||||
// Validate the ContextRoot
|
||||
if len(web.ContextRoot) == 0 {
|
||||
web.ContextRoot = DefaultContextRoot
|
||||
} else {
|
||||
trimmedContextRoot := strings.Trim(web.ContextRoot, "/")
|
||||
if len(trimmedContextRoot) == 0 {
|
||||
web.ContextRoot = DefaultContextRoot
|
||||
return
|
||||
}
|
||||
rootContextURL, err := url.Parse(trimmedContextRoot)
|
||||
if err != nil {
|
||||
panic("invalid context root:" + err.Error())
|
||||
}
|
||||
if rootContextURL.Path != trimmedContextRoot {
|
||||
panic("invalid context root: too complex")
|
||||
}
|
||||
web.ContextRoot = "/" + strings.Trim(rootContextURL.Path, "/") + "/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +56,8 @@ func (web *webConfig) validateAndSetDefaults() {
|
||||
func (web *webConfig) SocketAddress() string {
|
||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||
}
|
||||
|
||||
// PrependWithContextRoot appends the given path to the ContextRoot
|
||||
func (web *webConfig) PrependWithContextRoot(path string) string {
|
||||
return web.ContextRoot + strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
web := &webConfig{
|
||||
@@ -11,3 +14,86 @@ func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
|
||||
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("/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)
|
||||
}
|
||||
}
|
||||
|
||||
// validContextRootTest specifies all test case which should end up in
|
||||
// a valid context root used to bind the web interface to
|
||||
var validContextRootTests = []struct {
|
||||
name string
|
||||
path string
|
||||
expectedPath string
|
||||
}{
|
||||
{"Empty", "", "/"},
|
||||
{"/", "/", "/"},
|
||||
{"///", "///", "/"},
|
||||
{"Single character 'a'", "a", "/a/"},
|
||||
{"Slash at the beginning", "/status", "/status/"},
|
||||
{"Slashes at start and end", "/status/", "/status/"},
|
||||
{"Multiple slashes at start", "//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/"},
|
||||
}
|
||||
|
||||
func TestWebConfig_ValidContextRoots(t *testing.T) {
|
||||
for idx, test := range validContextRootTests {
|
||||
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||
expectValidResultForContextRoot(t, test.path, test.expectedPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"Only a fragment identifier", "#"},
|
||||
{"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?"},
|
||||
}
|
||||
|
||||
func TestWebConfig_InvalidContextRoots(t *testing.T) {
|
||||
for idx, test := range invalidContextRootTests {
|
||||
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||
expectInvalidResultForContextRoot(t, test.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectInvalidResultForContextRoot(t *testing.T, path string) {
|
||||
defer func() { recover() }()
|
||||
|
||||
web := &webConfig{ContextRoot: path}
|
||||
web.validateAndSetDefaults()
|
||||
|
||||
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ const (
|
||||
// MattermostAlert is the AlertType for the mattermost alerting provider
|
||||
MattermostAlert AlertType = "mattermost"
|
||||
|
||||
// MessagebirdAlert is the AlertType for the messagebird alerting provider
|
||||
MessagebirdAlert AlertType = "messagebird"
|
||||
|
||||
// PagerDutyAlert is the AlertType for the pagerduty alerting provider
|
||||
PagerDutyAlert AlertType = "pagerduty"
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
45
main.go
45
main.go
@@ -18,24 +18,26 @@ 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("/api/v1/results", resultsHandler)
|
||||
http.HandleFunc("/health", healthHandler)
|
||||
http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static"))))
|
||||
http.HandleFunc("/favicon.ico", favIconHandler) // favicon needs to be always served from the root
|
||||
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")))))
|
||||
|
||||
if cfg.Metrics {
|
||||
http.Handle("/metrics", promhttp.Handler())
|
||||
http.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler())
|
||||
}
|
||||
log.Printf("[main][main] Listening on %s\n", cfg.Web.SocketAddress())
|
||||
log.Printf("[main][main] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
|
||||
go watchdog.Monitor(cfg)
|
||||
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
|
||||
}
|
||||
@@ -54,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)
|
||||
@@ -88,3 +90,8 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
|
||||
}
|
||||
|
||||
// favIconHandler handles requests for /favicon.ico
|
||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, "./static/favicon.ico")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>Health Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="./bootstrap.min.css" />
|
||||
<style>
|
||||
html, body {
|
||||
background-color: #f7f9fb;
|
||||
@@ -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>
|
||||
@@ -129,11 +136,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/jquery.min.js"></script>
|
||||
<script src="./jquery.min.js"></script>
|
||||
|
||||
<div id="social">
|
||||
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
||||
<img src="/github.png" alt="GitHub" width="32" height="auto" />
|
||||
<img src="./github.png" alt="GitHub" width="32" height="auto" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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