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 }}
|
||||||
108
README.md
108
README.md
@@ -27,6 +27,7 @@ core applications: https://status.twinnation.org/
|
|||||||
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
||||||
- [Configuring Twilio alerts](#configuring-twilio-alerts)
|
- [Configuring Twilio alerts](#configuring-twilio-alerts)
|
||||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||||
|
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||||
- [Kubernetes (ALPHA)](#kubernetes-alpha)
|
- [Kubernetes (ALPHA)](#kubernetes-alpha)
|
||||||
- [Auto Discovery](#auto-discovery)
|
- [Auto Discovery](#auto-discovery)
|
||||||
@@ -42,6 +43,8 @@ core applications: https://status.twinnation.org/
|
|||||||
- [Monitoring using DNS queries](#monitoring-using-dns-queries)
|
- [Monitoring using DNS queries](#monitoring-using-dns-queries)
|
||||||
- [Basic authentication](#basic-authentication)
|
- [Basic authentication](#basic-authentication)
|
||||||
- [disable-monitoring-lock](#disable-monitoring-lock)
|
- [disable-monitoring-lock](#disable-monitoring-lock)
|
||||||
|
- [Service groups](#service-groups)
|
||||||
|
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## 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.
|
- **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.
|
- **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.
|
- **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**
|
- **Metrics**
|
||||||
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
|
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
|
||||||
- **Service auto discovery in Kubernetes** (ALPHA)
|
- **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` |
|
| `metrics` | Whether to expose metrics at /metrics | `false` |
|
||||||
| `services` | List of services to monitor | Required `[]` |
|
| `services` | List of services to monitor | Required `[]` |
|
||||||
| `services[].name` | Name of the service. Can be anything. | 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[].url` | URL to send the request to | Required `""` |
|
||||||
| `services[].method` | Request method | `GET` |
|
| `services[].method` | Request method | `GET` |
|
||||||
| `services[].insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
|
| `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` | 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-type` | Query type for DNS service | `""` |
|
||||||
| `services[].dns.query-name` | Query name 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[].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[].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` |
|
| `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` | Configuration for alerts of type `mattermost` | `{}` |
|
||||||
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
|
| `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.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` | Configuration for custom actions on failure or alerts | `{}` |
|
||||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||||
| `alerting.custom.method` | Request method | `GET` |
|
| `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.username` | Username for Basic authentication | Required `""` |
|
||||||
| `security.basic.password-sha512` | Password's SHA512 hash 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` |
|
| `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.address` | Address to listen on | `0.0.0.0` |
|
||||||
| `web.port` | Port to listen on | `8080` |
|
| `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)
|
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
|
#### Configuring custom alerts
|
||||||
|
|
||||||
While they're called alerts, you can use this feature to call anything.
|
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
|
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
|
## Running the tests
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -462,7 +500,7 @@ services:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
headers:
|
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:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- "[BODY].data.user[0].gender == female"
|
- "[BODY].data.user[0].gender == female"
|
||||||
@@ -539,7 +577,7 @@ established.
|
|||||||
Defining a `dns` configuration in a service will automatically mark that service as a service of type DNS:
|
Defining a `dns` configuration in a service will automatically mark that service as a service of type DNS:
|
||||||
```yaml
|
```yaml
|
||||||
- name: example dns query
|
- 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
|
interval: 30s
|
||||||
dns:
|
dns:
|
||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
@@ -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)
|
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 have a _lot_ of services to monitor
|
||||||
- You want to test multiple services at very short interval (< 5s)
|
- 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 (
|
import (
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"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/pagerduty"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
"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/alerting/provider/twilio"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ type Config struct {
|
|||||||
// Mattermost is the configuration for the mattermost alerting provider
|
// Mattermost is the configuration for the mattermost alerting provider
|
||||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
|
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 is the configuration for the pagerduty alerting provider
|
||||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
return provider
|
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
|
body := provider.Body
|
||||||
providerURL := provider.URL
|
providerURL := provider.URL
|
||||||
method := provider.Method
|
method := provider.Method
|
||||||
@@ -62,7 +62,7 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(method) == 0 {
|
if len(method) == 0 {
|
||||||
method = "GET"
|
method = http.MethodGet
|
||||||
}
|
}
|
||||||
bodyBuffer := bytes.NewBuffer([]byte(body))
|
bodyBuffer := bytes.NewBuffer([]byte(body))
|
||||||
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
|
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
|
// Send a request to the alert provider and return the body
|
||||||
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
|
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)
|
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package custom
|
package custom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/core"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
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 (
|
const (
|
||||||
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
|
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
|
||||||
ExpectedBody = "service-name,alert-description,RESOLVED"
|
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]",
|
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Headers: nil,
|
Headers: nil,
|
||||||
}
|
}
|
||||||
request := customAlertProvider.buildRequest("service-name", "alert-description", true)
|
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||||
if request.URL.String() != ExpectedURL {
|
if request.URL.String() != ExpectedURL {
|
||||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
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 (
|
const (
|
||||||
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
|
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
|
||||||
ExpectedBody = "service-name,alert-description,TRIGGERED"
|
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]",
|
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Headers: map[string]string{"Authorization": "Basic hunter2"},
|
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 {
|
if request.URL.String() != ExpectedURL {
|
||||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package mattermost
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
@@ -39,8 +41,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return &custom.AlertProvider{
|
||||||
URL: provider.WebhookURL,
|
URL: provider.WebhookURL,
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Insecure: provider.Insecure,
|
Insecure: provider.Insecure,
|
||||||
Body: fmt.Sprintf(`{
|
Body: fmt.Sprintf(`{
|
||||||
"text": "",
|
"text": "",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package mattermost
|
package mattermost
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/core"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
@@ -19,7 +21,7 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||||
//
|
//
|
||||||
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
// 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
|
var message, eventAction, resolveKey string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
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{
|
return &custom.AlertProvider{
|
||||||
URL: "https://events.pagerduty.com/v2/enqueue",
|
URL: "https://events.pagerduty.com/v2/enqueue",
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Body: fmt.Sprintf(`{
|
Body: fmt.Sprintf(`{
|
||||||
"routing_key": "%s",
|
"routing_key": "%s",
|
||||||
"dedup_key": "%s",
|
"dedup_key": "%s",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package pagerduty
|
package pagerduty
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/core"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"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/pagerduty"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
"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/alerting/provider/twilio"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
@@ -24,5 +25,6 @@ var (
|
|||||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package slack
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwinProduction/gatus/core"
|
||||||
@@ -38,7 +39,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return &custom.AlertProvider{
|
||||||
URL: provider.WebhookURL,
|
URL: provider.WebhookURL,
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Body: fmt.Sprintf(`{
|
Body: fmt.Sprintf(`{
|
||||||
"text": "",
|
"text": "",
|
||||||
"attachments": [
|
"attachments": [
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package twilio
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwinProduction/gatus/core"
|
||||||
"net/url"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
// 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
|
// 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
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
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{
|
return &custom.AlertProvider{
|
||||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Body: url.Values{
|
Body: url.Values{
|
||||||
"To": {provider.To},
|
"To": {provider.To},
|
||||||
"From": {provider.From},
|
"From": {provider.From},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package twilio
|
package twilio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/core"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwinProduction/gatus/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||||
|
|||||||
30
config.yaml
30
config.yaml
@@ -1,15 +1,37 @@
|
|||||||
metrics: true
|
|
||||||
services:
|
services:
|
||||||
- name: twinnation
|
- name: frontend
|
||||||
|
group: core
|
||||||
url: "https://twinnation.org/health"
|
url: "https://twinnation.org/health"
|
||||||
interval: 30s
|
interval: 1m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- "[BODY].status == UP"
|
- "[BODY].status == UP"
|
||||||
- "[RESPONSE_TIME] < 1000"
|
- "[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
|
- name: cat-fact
|
||||||
url: "https://cat-fact.herokuapp.com/facts/random"
|
url: "https://cat-fact.herokuapp.com/facts/random"
|
||||||
interval: 1m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- "[BODY].deleted == false"
|
- "[BODY].deleted == false"
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const (
|
|||||||
|
|
||||||
// DefaultPort is the default port the service will listen on
|
// DefaultPort is the default port the service will listen on
|
||||||
DefaultPort = 8080
|
DefaultPort = 8080
|
||||||
|
|
||||||
|
// DefaultContextRoot is the default context root of the web application
|
||||||
|
DefaultContextRoot = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -84,6 +87,7 @@ func Get() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load loads a custom configuration file
|
// 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 {
|
func Load(configFile string) error {
|
||||||
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
|
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
|
||||||
cfg, err := readConfigurationFile(configFile)
|
cfg, err := readConfigurationFile(configFile)
|
||||||
@@ -143,7 +147,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
|||||||
|
|
||||||
func validateWebConfig(config *Config) {
|
func validateWebConfig(config *Config) {
|
||||||
if config.Web == nil {
|
if config.Web == nil {
|
||||||
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort}
|
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
|
||||||
} else {
|
} else {
|
||||||
config.Web.validateAndSetDefaults()
|
config.Web.validateAndSetDefaults()
|
||||||
}
|
}
|
||||||
@@ -198,6 +202,7 @@ func validateAlertingConfig(config *Config) {
|
|||||||
alertTypes := []core.AlertType{
|
alertTypes := []core.AlertType{
|
||||||
core.SlackAlert,
|
core.SlackAlert,
|
||||||
core.MattermostAlert,
|
core.MattermostAlert,
|
||||||
|
core.MessagebirdAlert,
|
||||||
core.TwilioAlert,
|
core.TwilioAlert,
|
||||||
core.PagerDutyAlert,
|
core.PagerDutyAlert,
|
||||||
core.CustomAlert,
|
core.CustomAlert,
|
||||||
@@ -234,6 +239,12 @@ func GetAlertingProviderByAlertType(config *Config, alertType core.AlertType) pr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return config.Alerting.Mattermost
|
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:
|
case core.TwilioAlert:
|
||||||
if config.Alerting.Twilio == nil {
|
if config.Alerting.Twilio == nil {
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is 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 {
|
if config.Web.Port != DefaultPort {
|
||||||
t.Errorf("Port should have been %d, because it is the default value", 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) {
|
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) {
|
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
|
||||||
defer func() { recover() }()
|
defer func() { recover() }()
|
||||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||||
@@ -260,6 +301,9 @@ services:
|
|||||||
if config.Web.Port != DefaultPort {
|
if config.Web.Port != DefaultPort {
|
||||||
t.Errorf("Port should have been %d, because it is the default value", 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) {
|
func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {
|
||||||
@@ -322,6 +366,10 @@ alerting:
|
|||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
pagerduty:
|
pagerduty:
|
||||||
integration-key: "00000000000000000000000000000000"
|
integration-key: "00000000000000000000000000000000"
|
||||||
|
messagebird:
|
||||||
|
access-key: "1"
|
||||||
|
originator: "31619191918"
|
||||||
|
recipients: "31619191919"
|
||||||
services:
|
services:
|
||||||
- name: twinnation
|
- name: twinnation
|
||||||
url: https://twinnation.org/health
|
url: https://twinnation.org/health
|
||||||
@@ -333,6 +381,8 @@ services:
|
|||||||
failure-threshold: 7
|
failure-threshold: 7
|
||||||
success-threshold: 5
|
success-threshold: 5
|
||||||
description: "Healthcheck failed 7 times in a row"
|
description: "Healthcheck failed 7 times in a row"
|
||||||
|
- type: messagebird
|
||||||
|
enabled: true
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
`))
|
`))
|
||||||
@@ -357,9 +407,21 @@ services:
|
|||||||
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
|
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
|
||||||
t.Fatal("PagerDuty alerting config should've been valid")
|
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" {
|
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
|
||||||
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
|
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 {
|
if len(config.Services) != 1 {
|
||||||
t.Error("There should've been 1 service")
|
t.Error("There should've been 1 service")
|
||||||
}
|
}
|
||||||
@@ -372,8 +434,8 @@ services:
|
|||||||
if config.Services[0].Alerts == nil {
|
if config.Services[0].Alerts == nil {
|
||||||
t.Fatal("The service alerts shouldn't have been nil")
|
t.Fatal("The service alerts shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if len(config.Services[0].Alerts) != 2 {
|
if len(config.Services[0].Alerts) != 3 {
|
||||||
t.Fatal("There should've been 2 alert configured")
|
t.Fatal("There should've been 3 alert configured")
|
||||||
}
|
}
|
||||||
if !config.Services[0].Alerts[0].Enabled {
|
if !config.Services[0].Alerts[0].Enabled {
|
||||||
t.Error("The alert should've been 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" {
|
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)
|
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) {
|
func TestParseAndValidateConfigBytesWithInvalidPagerDutyAlertingConfig(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// webConfig is the structure which supports the configuration of the endpoint
|
// 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 to listen on (default to 8080 specified by DefaultPort)
|
||||||
Port int `yaml:"port"`
|
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
|
// validateAndSetDefaults checks and sets the default values for fields that are not set
|
||||||
// in given in DefaultAddress and DefaultPort if necessary
|
|
||||||
func (web *webConfig) validateAndSetDefaults() {
|
func (web *webConfig) validateAndSetDefaults() {
|
||||||
|
// Validate the Address
|
||||||
if len(web.Address) == 0 {
|
if len(web.Address) == 0 {
|
||||||
web.Address = DefaultAddress
|
web.Address = DefaultAddress
|
||||||
}
|
}
|
||||||
|
// Validate the Port
|
||||||
if web.Port == 0 {
|
if web.Port == 0 {
|
||||||
web.Port = DefaultPort
|
web.Port = DefaultPort
|
||||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
} 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 {
|
func (web *webConfig) SocketAddress() string {
|
||||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
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
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
func TestWebConfig_SocketAddress(t *testing.T) {
|
||||||
web := &webConfig{
|
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())
|
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 is the AlertType for the mattermost alerting provider
|
||||||
MattermostAlert AlertType = "mattermost"
|
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 is the AlertType for the pagerduty alerting provider
|
||||||
PagerDutyAlert AlertType = "pagerduty"
|
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 (
|
var (
|
||||||
// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name
|
// 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")
|
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 is the error with which gatus will panic if a dns is configured with invalid query type
|
||||||
ErrDNSWithInvalidQueryType = errors.New("invalid query type")
|
ErrDNSWithInvalidQueryType = errors.New("invalid query type")
|
||||||
)
|
)
|
||||||
@@ -19,9 +20,11 @@ const (
|
|||||||
dnsPort = 53
|
dnsPort = 53
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DNS is the configuration for a Service of type DNS
|
||||||
type DNS struct {
|
type DNS struct {
|
||||||
// QueryType is the type for the DNS records like A, AAAA, CNAME...
|
// QueryType is the type for the DNS records like A, AAAA, CNAME...
|
||||||
QueryType string `yaml:"query-type"`
|
QueryType string `yaml:"query-type"`
|
||||||
|
|
||||||
// QueryName is the query for DNS
|
// QueryName is the query for DNS
|
||||||
QueryName string `yaml:"query-name"`
|
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"
|
"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
|
// Result of the evaluation of a Service
|
||||||
type Result struct {
|
type Result struct {
|
||||||
// HTTPStatus is the HTTP response status code
|
// HTTPStatus is the HTTP response status code
|
||||||
HTTPStatus int `json:"status"`
|
HTTPStatus int `json:"status"`
|
||||||
|
|
||||||
// DNSRCode is the response code of DNS query in human readable version
|
// DNSRCode is the response code of a DNS query in a human readable format
|
||||||
DNSRCode string `json:"dns-rcode"`
|
DNSRCode string `json:"-"`
|
||||||
|
|
||||||
// Body is the response body
|
// Body is the response body
|
||||||
Body []byte `json:"-"`
|
Body []byte `json:"-"`
|
||||||
@@ -50,14 +40,5 @@ type Result struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
|
||||||
// CertificateExpiration is the duration before the certificate expires
|
// CertificateExpiration is the duration before the certificate expires
|
||||||
CertificateExpiration time.Duration `json:"certificate-expiration,omitempty"`
|
CertificateExpiration time.Duration `json:"-"`
|
||||||
}
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
}
|
||||||
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"
|
"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 (
|
var (
|
||||||
// ErrServiceWithNoCondition is the error with which gatus will panic if a service is configured with no conditions
|
// 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")
|
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 of the service. Can be anything.
|
||||||
Name string `yaml:"name"`
|
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 to send the request to
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
|
|
||||||
@@ -79,6 +90,11 @@ func (service *Service) ValidateAndSetDefaults() {
|
|||||||
if len(service.Headers) == 0 {
|
if len(service.Headers) == 0 {
|
||||||
service.Headers = make(map[string]string)
|
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 {
|
for _, alert := range service.Alerts {
|
||||||
if alert.FailureThreshold <= 0 {
|
if alert.FailureThreshold <= 0 {
|
||||||
alert.FailureThreshold = 3
|
alert.FailureThreshold = 3
|
||||||
@@ -213,6 +229,9 @@ func (service *Service) buildHTTPRequest() *http.Request {
|
|||||||
request, _ := http.NewRequest(service.Method, service.URL, bodyBuffer)
|
request, _ := http.NewRequest(service.Method, service.URL, bodyBuffer)
|
||||||
for k, v := range service.Headers {
|
for k, v := range service.Headers {
|
||||||
request.Header.Set(k, v)
|
request.Header.Set(k, v)
|
||||||
|
if k == HostHeader {
|
||||||
|
request.Host = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||||
condition := Condition("[STATUS] == 200")
|
condition := Condition("[STATUS] == 200")
|
||||||
service := Service{
|
service := Service{
|
||||||
|
|||||||
3
gzip.go
3
gzip.go
@@ -20,11 +20,14 @@ type gzipResponseWriter struct {
|
|||||||
http.ResponseWriter
|
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) {
|
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||||
w.Header().Del("Content-Length")
|
w.Header().Del("Content-Length")
|
||||||
w.ResponseWriter.WriteHeader(status)
|
w.ResponseWriter.WriteHeader(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write writes len(b) bytes from b to the underlying data stream.
|
||||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||||
return w.Writer.Write(b)
|
return w.Writer.Write(b)
|
||||||
}
|
}
|
||||||
|
|||||||
45
main.go
45
main.go
@@ -18,24 +18,26 @@ import (
|
|||||||
const cacheTTL = 10 * time.Second
|
const cacheTTL = 10 * time.Second
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cachedServiceResults []byte
|
cachedServiceStatuses []byte
|
||||||
cachedServiceResultsGzipped []byte
|
cachedServiceStatusesGzipped []byte
|
||||||
cachedServiceResultsTimestamp time.Time
|
cachedServiceStatusesTimestamp time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := loadConfiguration()
|
cfg := loadConfiguration()
|
||||||
resultsHandler := serviceResultsHandler
|
statusesHandler := serviceStatusesHandler
|
||||||
if cfg.Security != nil && cfg.Security.IsValid() {
|
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("/favicon.ico", favIconHandler) // favicon needs to be always served from the root
|
||||||
http.HandleFunc("/health", healthHandler)
|
http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler)
|
||||||
http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static"))))
|
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 {
|
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)
|
go watchdog.Monitor(cfg)
|
||||||
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
|
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
|
||||||
}
|
}
|
||||||
@@ -54,29 +56,29 @@ func loadConfiguration() *config.Config {
|
|||||||
return config.Get()
|
return config.Get()
|
||||||
}
|
}
|
||||||
|
|
||||||
func serviceResultsHandler(writer http.ResponseWriter, r *http.Request) {
|
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||||
if isExpired := cachedServiceResultsTimestamp.IsZero() || time.Now().Sub(cachedServiceResultsTimestamp) > cacheTTL; isExpired {
|
if isExpired := cachedServiceStatusesTimestamp.IsZero() || time.Now().Sub(cachedServiceStatusesTimestamp) > cacheTTL; isExpired {
|
||||||
buffer := &bytes.Buffer{}
|
buffer := &bytes.Buffer{}
|
||||||
gzipWriter := gzip.NewWriter(buffer)
|
gzipWriter := gzip.NewWriter(buffer)
|
||||||
data, err := watchdog.GetJSONEncodedServiceResults()
|
data, err := watchdog.GetJSONEncodedServiceStatuses()
|
||||||
if err != nil {
|
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.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gzipWriter.Write(data)
|
gzipWriter.Write(data)
|
||||||
gzipWriter.Close()
|
gzipWriter.Close()
|
||||||
cachedServiceResults = data
|
cachedServiceStatuses = data
|
||||||
cachedServiceResultsGzipped = buffer.Bytes()
|
cachedServiceStatusesGzipped = buffer.Bytes()
|
||||||
cachedServiceResultsTimestamp = time.Now()
|
cachedServiceStatusesTimestamp = time.Now()
|
||||||
}
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
writer.Header().Set("Content-Encoding", "gzip")
|
writer.Header().Set("Content-Encoding", "gzip")
|
||||||
data = cachedServiceResultsGzipped
|
data = cachedServiceStatusesGzipped
|
||||||
} else {
|
} else {
|
||||||
data = cachedServiceResults
|
data = cachedServiceStatuses
|
||||||
}
|
}
|
||||||
writer.Header().Add("Content-type", "application/json")
|
writer.Header().Add("Content-type", "application/json")
|
||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
@@ -88,3 +90,8 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
|||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
|
_, _ = 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>
|
<head>
|
||||||
<title>Health Dashboard</title>
|
<title>Health Dashboard</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
background-color: #f7f9fb;
|
background-color: #f7f9fb;
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
#settings select:focus {
|
#settings select:focus {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
.service-group {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.service-group h5:hover {
|
||||||
|
color: #1b1e21 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -129,11 +136,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/jquery.min.js"></script>
|
<script src="./jquery.min.js"></script>
|
||||||
|
|
||||||
<div id="social">
|
<div id="social">
|
||||||
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,7 +169,7 @@
|
|||||||
function showTooltip(serviceName, index, element) {
|
function showTooltip(serviceName, index, element) {
|
||||||
userClickedStatus = false;
|
userClickedStatus = false;
|
||||||
clearTimeout(timerHandler);
|
clearTimeout(timerHandler);
|
||||||
let serviceResult = serviceStatuses[serviceName][index];
|
let serviceResult = serviceStatuses[serviceName].results[index];
|
||||||
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
|
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
|
||||||
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
|
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
|
||||||
// Populate the condition section
|
// Populate the condition section
|
||||||
@@ -212,15 +219,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusBadge(serviceName, index, success) {
|
function createStatusBadge(serviceStatusIndex, index, success) {
|
||||||
if (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() {
|
function refreshStatuses() {
|
||||||
$.getJSON("/api/v1/results", function (data) {
|
$.getJSON("./api/v1/statuses", function (data) {
|
||||||
// Update the table only if there's a change
|
// Update the table only if there's a change
|
||||||
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
||||||
serviceStatuses = data;
|
serviceStatuses = data;
|
||||||
@@ -230,17 +237,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTable() {
|
function buildTable() {
|
||||||
let output = "";
|
let outputByGroup = {};
|
||||||
for (let serviceName in serviceStatuses) {
|
for (let serviceStatusIndex in serviceStatuses) {
|
||||||
let serviceStatusOverTime = "";
|
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 minResponseTime = null;
|
||||||
let maxResponseTime = null;
|
let maxResponseTime = null;
|
||||||
let newestTimestamp = null;
|
let newestTimestamp = null;
|
||||||
let oldestTimestamp = null;
|
let oldestTimestamp = null;
|
||||||
for (let key in serviceStatuses[serviceName]) {
|
for (let resultIndex in serviceStatus.results) {
|
||||||
let serviceResult = serviceStatuses[serviceName][key];
|
let serviceResult = serviceStatus.results[resultIndex];
|
||||||
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
|
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
|
||||||
const responseTime = parseInt(serviceResult.duration/1000000);
|
const responseTime = parseInt(serviceResult.duration/1000000);
|
||||||
if (minResponseTime == null || minResponseTime > responseTime) {
|
if (minResponseTime == null || minResponseTime > responseTime) {
|
||||||
minResponseTime = responseTime;
|
minResponseTime = responseTime;
|
||||||
@@ -256,11 +264,11 @@
|
|||||||
oldestTimestamp = timestamp;
|
oldestTimestamp = timestamp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
output += ""
|
let output = ""
|
||||||
+ "<div class='container py-3 border-left border-right border-top border-black'>"
|
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
|
||||||
+ " <div class='row mb-2'>"
|
+ " <div class='row mb-2'>"
|
||||||
+ " <div class='col-md-10'>"
|
+ " <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>"
|
||||||
+ " <div class='col-md-2 text-right'>"
|
+ " <div class='col-md-2 text-right'>"
|
||||||
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
|
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
|
||||||
@@ -280,10 +288,59 @@
|
|||||||
+ " </div>"
|
+ " </div>"
|
||||||
+ " </div>"
|
+ " </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);
|
$("#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) {
|
function prettifyTimestamp(timestamp) {
|
||||||
let date = new Date(timestamp);
|
let date = new Date(timestamp);
|
||||||
let YYYY = date.getFullYear();
|
let YYYY = date.getFullYear();
|
||||||
@@ -318,15 +375,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setRefreshInterval(seconds) {
|
function setRefreshInterval(seconds) {
|
||||||
refreshResults();
|
refreshStatuses();
|
||||||
refreshIntervalHandler = setInterval(function() {
|
refreshIntervalHandler = setInterval(function() {
|
||||||
refreshResults();
|
refreshStatuses();
|
||||||
}, seconds * 1000)
|
}, seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#refresh-rate").change(function() {
|
$("#refresh-rate").change(function() {
|
||||||
clearInterval(refreshIntervalHandler);
|
clearInterval(refreshIntervalHandler);
|
||||||
setRefreshInterval($(this).val())
|
setRefreshInterval($(this).val());
|
||||||
});
|
});
|
||||||
setRefreshInterval(30);
|
setRefreshInterval(30);
|
||||||
$("#refresh-rate").val(30);
|
$("#refresh-rate").val(30);
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serviceResults = make(map[string][]*core.Result)
|
serviceStatuses = make(map[string]*core.ServiceStatus)
|
||||||
|
|
||||||
// serviceResultsMutex is used to prevent concurrent map access
|
// serviceStatusesMutex is used to prevent concurrent map access
|
||||||
serviceResultsMutex sync.RWMutex
|
serviceStatusesMutex sync.RWMutex
|
||||||
|
|
||||||
// monitoringMutex is used to prevent multiple services from being evaluated at the same time.
|
// monitoringMutex is used to prevent multiple services from being evaluated at the same time.
|
||||||
// Without this, conditions using response time may become inaccurate.
|
// Without this, conditions using response time may become inaccurate.
|
||||||
monitoringMutex sync.Mutex
|
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.
|
// The reason why the encoding is done here is because we use a mutex to prevent concurrent map access.
|
||||||
func GetJSONEncodedServiceResults() ([]byte, error) {
|
func GetJSONEncodedServiceStatuses() ([]byte, error) {
|
||||||
serviceResultsMutex.RLock()
|
serviceStatusesMutex.RLock()
|
||||||
data, err := json.Marshal(serviceResults)
|
data, err := json.Marshal(serviceStatuses)
|
||||||
serviceResultsMutex.RUnlock()
|
serviceStatusesMutex.RUnlock()
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +55,7 @@ func monitor(service *core.Service) {
|
|||||||
}
|
}
|
||||||
result := service.EvaluateHealth()
|
result := service.EvaluateHealth()
|
||||||
metric.PublishMetricsForService(service, result)
|
metric.PublishMetricsForService(service, result)
|
||||||
serviceResultsMutex.Lock()
|
UpdateServiceStatuses(service, result)
|
||||||
serviceResults[service.Name] = append(serviceResults[service.Name], result)
|
|
||||||
if len(serviceResults[service.Name]) > 20 {
|
|
||||||
serviceResults[service.Name] = serviceResults[service.Name][1:]
|
|
||||||
}
|
|
||||||
serviceResultsMutex.Unlock()
|
|
||||||
var extra string
|
var extra string
|
||||||
if !result.Success {
|
if !result.Success {
|
||||||
extra = fmt.Sprintf("responseBody=%s", result.Body)
|
extra = fmt.Sprintf("responseBody=%s", result.Body)
|
||||||
@@ -83,3 +78,16 @@ func monitor(service *core.Service) {
|
|||||||
time.Sleep(service.Interval)
|
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